This is the first article in a series. The follow-up is titled How to build JavaScript's fastest “deep clone” function.
This is a short article about how I built JavaScript's fastest "deep equals" function.
tl;dr
The implementation we'll be talking about today is:
-
35-65x faster than NodeJS's built-in
isDeepStrictEqual
-
50-110x faster than
Lodash.isEqual
If you're skeptical, you can run the benchmarks yourself on Bolt by clicking "Code" and typing node index.mjs
in the terminal.
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 a function that takes 2 arguments and returns a boolean indicating whether they are "the same".
By "the same", typically 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 it's complicated. To see what I mean, try typing this out in your browser's DevTools:
console.assert(0.1 + 0.2 === 0.3)
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. We want to know 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 the 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:
"roll our own" — offers the best performance, but it's annoying and brittle
use an off-the-shelf solution — convenient, but comes with the worst performance profile
write our schemas using
effect
— this solves #1 and partially alleviates #2, but requires us to migrate toeffect
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.
Implementing JavaScript's fastest “deep equals”
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.deepEqual
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.deepEqual(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
zx.deepEqual
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, the benchmarks, or the implementation.
Here are the benchmarks for addressEquals
. I used fast-check
to generate the inputs, and mitata
as the benchmark runner.
clk: ~3.02 GHz
cpu: Apple M1 Pro
runtime: node 22.13.1 (arm64-darwin)
Underscore ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 624.51 ns
Lodash ┤■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 735.88 ns
NodeJS ┤■■■■■■■■■■■■■■■■■■■ 420.93 ns
traversable ┤■■■ 77.56 ns
FastEquals ┤■■■■ 88.68 ns
FastIsEqual ┤■■■■■■■■■■■■■ 275.99 ns
ReactHooks ┤■■■■■■■■■■ 215.57 ns
JsonJoy ┤■■■■ 91.11 ns
Effect ┤■■■■■■■■■■ 227.12 ns
zx.deepEqual ┤ 6.77 ns
summary
zx.deepEqual
11.45x faster than traversable
13.1x faster than FastEquals
13.46x faster than JsonJoy
31.84x faster than ReactHooks
33.54x faster than Effect
40.76x faster than FastIsEqual
62.17x faster than NodeJS
92.24x faster than Underscore
108.69x faster than Lodash
Thanks for reading!
If you enjoyed reading this, you might enjoy the follow-up, where I use the same technique to "draw the rest of the owl":
Updates
Since publishing this article, I've added support for:
Links
- Bolt sandbox where you can run the benchmarks
- The benchmarks themselves
- Examples of the generated code
- How the benchmarks were conducted
- The implementation
Top comments (0)