How I built JavaScript’s fastest “deep equals” function

This is a short article about how I built JavaScript’s fastest “deep equals” function.

Before we get into the how, let’s first make sure we’re on the same page about the problem we’re trying to solve.

What we mean when we say 2 things are the same

An equivalence relation, or “deep equals” in the parlance of the JS ecosystem, is simply a function that takes 2 arguments and returns a boolean indicating whether they are “the same”.

By “the same”, usually we mean equal by value, not equal by reference.

Example

These are equal by reference:

const value1 = { abc: 1 }
const value2 = value1

value1 === value2 // true

These are equal by value:

const value1 = { abc: 1 }
const value2 = { abc: 1 }

value1 === value2 // false

Note that value 1 === value2 returns false in the second example.

The issue is that, given non-primitive operands, the === operator computes an identity rather than an equivalence.

Put differently, the === operator answers the question, “Do both pointers point to the same place in memory?” rather than “Are both values structurally the same?”

Why does it work that way?

The short answer

The short answer is that that’s the spec.

The long answer

Gather round the fire, let’s talk about what we mean when we say two things are the same…

The long answer is that this is part of a much larger conversation about identity and sameness. And while I think this is an interesting topic, it’s been covered elsewhere, so let’s sidestep it for now.

What do we actually want?

What we actually want is a more relaxed definition of equality. What we want to know is whether 2 values are equal according to some criteria.

Example

Let’s say we’re working with the following data model:

import { z } from 'zod'

type Address = z.infer<typeof Address>
const Address = z.object({
  street1: z.string(),
  street2: z.optional(z.string()),
  city: z.string(),
})

How can we tell 2 addresses apart?

A “naive” solution

Arguably the easiest solution is to hand-roll our own:

const addressEquals = (x: Address, y: Address) => {
  if (x === y) return true
  if (x.street1 !== y.street1) return false
  if (x.street2 !== y.street2) return false
  if (x.city !== y.city) return false
  return true
}

Using it looks like this:

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '221B Baker St', city: 'London' }
) // => true

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '4 Privet Dr', city: 'Little Whinging' }
) // => false

Advantages

  • We control the implementation
  • We have visibility into its behavior
  • Great performance

Disadvantages

  • Tedious to write
  • Easy to screw up
  • Subject to rot: if we add a new property to our Address type, it’s easy to forget to update our equals function

A “heavy-handed” solution

Another way to solve the problem is to use an off-the-shelf solution, like Lodash’s isEqual function:

import { isEqual } from 'lodash.isEqual'

isEqual(
  { street1: '221B Baker St', city: 'London' },
  { street1: '221B Baker St', city: 'London' }
) // => true

isEqual(
  { street1: '221B Baker St', city: 'London' },
  { street1: '4 Privet Dr', city: 'Little Whinging' }
) // => false

This is useful in a pinch, but it isn’t perfect. Namely, we’ve solved one problem by introducing a new one: isEqual comes with a performance penalty, because it has to traverse both data structures recursively to compute the result.

Do we have any other options?

Getting closer

Another thing we could do is swap out our schema library for one that lets us derive an equals function from our schema.

The effect library supports this. Let’s see what that looks like:

import { Schema } from 'effect'

const Address = Schema.Struct({
  street1: Schema.String,
  street2: Schema.optionalWith(Schema.String),
  city: Schema.String,
})

// deriving an equals function from `Address`:
const addressEquals = Schema.equivalence(Address)

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '221B Baker St', city: 'London' }
) // => true

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '4 Privet Dr', city: 'Little Whinging' }
) // => false

Advantages

  • Better performance than isEqual
  • Implementations stay in sync

Disadvantages

  • We have to switch our schema library, which may or may not be viable
  • The performance is better, but only marginally so

Comparison

To recap, we have roughly 3 options, each with its own set of tradeoffs. We can:

  1. “roll our own” — offers the best performance, but it’s annoying and brittle

  2. use an off-the-shelf solution — convenient, but comes with the worst performance profile

  3. write our schemas using effect — this solves #1 and partially alleviates #2, but requires us to migrate to effect

Wouldn’t it be great if we had a way to somehow get the performance of hand-written equals functions, without having to actually write them by hand?

Turns out, we can.

The fastest isEqual function possible

Remember our “naive” solution? Here it is again:

const Address = z.object({
  street1: z.string(),
  street2: z.optional(z.string()),
  city: z.string(),
})

const addressEquals = (x: Address, y: Address) => {
  if (x === y) return true
  if (x.street1 !== y.street1) return false
  if (x.street2 !== y.street2) return false
  if (x.city !== y.city) return false
  return true
}

As it turns out, our naive solution is also super fast. In fact, it would be almost impossible to make it faster.

Can we use our schema to generate the super fast implementation as a string?

If we could do that, we could write that implementation to disc, or use JavaScript’s native Function constructor to turn the string into code.

That would look something like this:

const addressEquals = Function('x', 'y', `
  if (x === y) return true
  if (x.street1 !== y.street1) return false
  if (x.street2 !== y.street2) return false
  if (x.city !== y.city) return false
  return true
`)

If you’ve never seen this trick before, chances are you’re probably using it already: if you use zod@4, arktype or @sinclair/typebox, they all use the same trick under the hood when validating data.

The performance increase can be dramatic — if this is your first time seeing this technique, this video by the author of arri does a great job covering why it’s fast, and how it works.

So, we know what we want (to write a function that takes a zod schema, and builds up a string that will become our equals function) — but how would we go about doing that?

Introducing @traversable/zod

That’s exactly what zx.equals from @traversable/zod does.

Using it looks like this:

import { z } from 'zod'
import { zx } from '@traversable/zod'

const Address = z.object({
  street1: z.string(),
  street2: z.optional(z.string()),
  city: z.string(),
})

const addressEquals = zx.equals(Address)

addressEquals(
  { street1: '221B Baker St', city: 'London' }, 
  { street1: '221B Baker St', city: 'London' }
) // => true

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '4 Privet Dr', city: 'Little Whinging' }
) // => false

That’s it. You write your zod schemas as usual, and you get the fastest possible “deep equals” function for free.

If you’re curious how much faster, check out the benchmarks below.

Performance comparison

tl;dr, zx.equals performs better than every implementation I could find, in every category, on every run.

I’ve included links at bottom in case you’d like to dig into what the generated code looks like, how I conducted the benchmarks, and how it was implemented.

🏁 ››› generated input: boolean array
Winner: ❲zx.equals❳ is
  1.54x faster than ❲EffectEquals❳
  1.62x faster than ❲ReactHooksDeepEqual❳
  1.74x faster than ❲FastEquals❳
  1.79x faster than ❲JsonJoyDeepEqual❳
  1.87x faster than ❲@traversable/Equal.deep❳
  2.08x faster than ❲FastIsEqual❳
  2.20x faster than ❲TypeBoxEqual❳
  2.40x faster than ❲UnderscoreIsEqual❳
  3.60x faster than ❲LodashIsEqual❳
  7.77x faster than ❲NodeJS.isDeepStrictEqual❳

🏁 ››› generated input: string array
Winner: ❲zx.equals❳ is
  1.62x faster than ❲EffectEquals❳
  1.67x faster than ❲ReactHooksDeepEqual❳
  1.76x faster than ❲FastEquals❳
  1.80x faster than ❲FastIsEqual❳
  1.84x faster than ❲JsonJoyDeepEqual❳
  1.93x faster than ❲@traversable/Equal.deep❳
  2.22x faster than ❲UnderscoreIsEqual❳
  2.29x faster than ❲TypeBoxEqual❳
  3.08x faster than ❲LodashIsEqual❳
  6.21x faster than ❲NodeJS.isDeepStrictEqual❳

🏁 ››› generated input: boolean object
Winner: ❲zx.equals❳ is
  2.78x faster than ❲JsonJoyDeepEqual❳
  3.19x faster than ❲@traversable/Equal.deep❳
  3.46x faster than ❲FastEquals❳
  3.70x faster than ❲ReactHooksDeepEqual❳
  4.62x faster than ❲EffectEquals❳
  4.70x faster than ❲UnderscoreIsEqual❳
  5.14x faster than ❲TypeBoxEqual❳
  5.53x faster than ❲FastIsEqual❳
  10.49x faster than ❲NodeJS.isDeepStrictEqual❳
  11.77x faster than ❲LodashIsEqual❳

🏁 ››› generated input: string object
Winner: ❲zx.equals❳ is
  2.82x faster than ❲JsonJoyDeepEqual❳
  3.28x faster than ❲@traversable/Equal.deep❳
  3.47x faster than ❲FastEquals❳
  3.72x faster than ❲ReactHooksDeepEqual❳
  4.55x faster than ❲EffectEquals❳
  4.68x faster than ❲UnderscoreIsEqual❳
  5.02x faster than ❲TypeBoxEqual❳
  5.40x faster than ❲FastIsEqual❳
  9.89x faster than ❲NodeJS.isDeepStrictEqual❳
  10.85x faster than ❲LodashIsEqual❳

🏁 ››› generated input: deep object
Winner: ❲zx.equals❳ is
  4.32x faster than ❲JsonJoyDeepEqual❳
  4.46x faster than ❲@traversable/Equal.deep❳
  5.81x faster than ❲FastEquals❳
  5.97x faster than ❲ReactHooksDeepEqual❳
  6.27x faster than ❲UnderscoreIsEqual❳
  7.32x faster than ❲EffectEquals❳
  7.87x faster than ❲TypeBoxEqual❳
  8.00x faster than ❲FastIsEqual❳
  12.63x faster than ❲NodeJS.isDeepStrictEqual❳
  13.41x faster than ❲LodashIsEqual❳

Notes

  1. Since we’re using the Function constructor, there’s a caveat, which is that you can’t use zx.equals with a frontend that was deployed using Cloudflare workers.

As a workaround, if you’re able to control your build, you can use zx.equals.writeable to write the equals functions to disc ahead of time.

Or, if that doesn’t work, you can use zx.equals.classic to get the same behavior as effect. The performance won’t be as good, but it will still be better than most of the libraries on this list.

  1. As of 2025-07-11, we’ve also added support for TypeBox. To use it, simply install @traversable/typebox and pass your TypeBox schema to box.equals:
import * as T from '@sinclair/typebox' 
import { box } from '@traversable/typebox' 

const Address = T.Object({
  street1: T.String(),
  street2: T.Optional(T.String()),
  city: T.String(),
})

const addressEquals = box.equals(Address)

addressEquals(
  { street1: '221B Baker St', city: 'London' }, 
  { street1: '221B Baker St', city: 'London' }
) // => true

addressEquals(
  { street1: '221B Baker St', city: 'London' },
  { street1: '4 Privet Dr', city: 'Little Whinging' }
) // => false

Links

Similar Posts