Deep Equality in JavaScript
Compare values structurally: primitives, NaN, arrays, plain objects, dates, and the cycle that will infinite-loop a naive recursion.
- javascript
- objects
- equality
- interview
{ x: 1 } === { x: 1 } is false. A candidate then writes JSON.stringify(a) === JSON.stringify(b) and ships three bugs: key order, undefined keys vanishing, NaN becoming null. The next candidate recurses on properties and hangs the tab on const a = {}; a.self = a.
Deep equality asks whether two values have the same shape and the same leaf values. It is not Object.is alone (that is SameValue, one level of identity). It is not the shallow walk in shallow vs deep equality. This page is the function you implement: primitives, NaN, arrays, plain objects, Date, and a visited-pair set so cycles terminate.
Object.isis the leaf check. That makesNaNequalNaNand+0unequal-0.- Arrays compare length and index slots. Plain objects compare own enumerable keys.
DatecomparesgetTime(), not identity.- Cycles need a visited pair (
apreviously matchedb), not a singleseenset of objects.JSON.stringifyis not deep equal.MapandSetare extra credit with their own key rules.
Same shape and same values, with a visited pair set
Start with SameValue. If that is true, you are done: same primitive, same reference, both NaN. If types disagree, or either side is a non-object after that, they are not equal.
function deepEqual(a, b, seen = new WeakMap()) {
if (Object.is(a, b)) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return false;
if (typeof a !== 'object') return false;
if (seen.has(a)) return seen.get(a) === b;
seen.set(a, b);
if (a instanceof Date || b instanceof Date) {
return (
a instanceof Date &&
b instanceof Date &&
Object.is(a.getTime(), b.getTime())
);
}
const aIsArr = Array.isArray(a);
const bIsArr = Array.isArray(b);
if (aIsArr || bIsArr) {
if (aIsArr !== bIsArr || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i], seen)) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.hasOwn(b, key)) return false;
if (!deepEqual(a[key], b[key], seen)) return false;
}
return true;
}
| Pair | Result | Why |
|---|---|---|
1, 1 |
true | Object.is |
NaN, NaN |
true | SameValue, unlike === |
+0, -0 |
false | SameValue; === would be true |
{ x: 1 }, { x: 1 } |
true | one own key, leaf 1 |
[1, 2], [1, 2] |
true | length and slots |
new Date(0), new Date(0) |
true | same getTime() |
{}, [] |
false | array vs object |
Object.is vs === is the leaf rule. Deep equal is that rule applied to every corresponding slot.
Why a pair, not a boolean “seen”
A single WeakSet of visited objects treats the second time you reach a as “already equal,” even if this time the other side is a different node. You need: “I already decided a compares to b.” A WeakMap from a to b is that pair.
const a = {};
a.self = a;
const b = {};
b.self = b;
deepEqual(a, b); // true: each cycle pairs with the other root
const c = {};
c.self = {};
deepEqual(a, c); // false: c.self is a different empty object, not a cycle back to c
Without seen, a.self recurses forever. JSON.stringify throws TypeError on cycles. structuredClone can clone cycles (structured clone); equality still needs the pair map.
Invalid dates: getTime() is NaN, and Object.is(NaN, NaN) is true, so two invalid Date objects compare equal. Say that if they mention Invalid Date.
Holes, inherited keys, prototypes
Index loops read holes as undefined. [1, , 3] and [1, undefined, 3] therefore compare equal in this implementation. Object.keys skips holes, so a keys-only walk would disagree. Pick the index walk for arrays and mention holes.
Object.keys is own enumerable string keys. It skips symbols and non-enumerable fields. { ...inherited } vs a prototype value is a different object. Object.hasOwn on the other side avoids counting inherited keys as present.
Prototypes: { x: 1 } and Object.create({ x: 1 }) are not equal here (Object.keys on the second is empty). If the interviewer wants prototype walks, that is for...in plus hasOwn filtering, and it is a different spec.
Map and Set (extra)
Map key equality is SameValueZero (NaN matches, +0 matches -0). Deep-equal maps usually:
- Require both sides to be
Map(or bothSet). - Compare
.size. - For each
[key, value]ofa, find a key onbthat is deeply equal (orObject.isfor primitives) and whose value is deeply equal.
That search is O(n²) if keys are objects. Interview quality: name the cost, implement SameValueZero keys if keys are primitives, and don’t pretend JSON.stringify on a Map works (JSON.stringify(new Map()) is "{}"). Map and Set cover iteration order: insertion order is observable; deep equal usually ignores order for sets of equal elements, which is another decision to say out loud.
function equalMap(a, b, seen) {
if (!(b instanceof Map) || a.size !== b.size) return false;
for (const [key, value] of a) {
if (!b.has(key)) return false; // primitive / SameValueZero keys
if (!deepEqual(value, b.get(key), seen)) return false;
}
return true;
}
b.has(key) is enough when keys are primitives or the same object reference. Deep-equal object keys need a scan. Don’t write the scan unless asked.
What you can refuse
A total deep equal for boxed numbers, RegExp flags, ArrayBuffer bytes, sparse vs dense, and arguments objects is a library. On a whiteboard, primitives + arrays + plain objects + Date + cycles is a complete answer. Offer Map/Set as the next fork.
Deep equality footguns
typeof null. AfterObject.isfails,typeof null === 'object'. Thea === null || b === nullguard stops you from walkingnull.===for leaves. LosesNaN. UseObject.is.JSON.stringify. Key order,undefined,NaN, functions,Map, cycles. Not a fallback.- Comparing
Datewith==. Coerces to a number, butDatevs timestamp mixed types should be false if you already requiredinstanceof Dateon both. - Mutating
seenwithout pairing.seen.set(a, true)cannot tell whichbyou matched.
Deep equal is expensive. UI code should prefer stable references and shallow checks. Write this function for tests, reducers, and interview rooms, not for every render.
Interview out loud
“I use Object.is first so NaN matches and signed zeros do not. If both values are objects, I record the pair in a WeakMap so a cycle returns the previous decision instead of recursing forever. Arrays compare length and each index; plain objects compare own enumerable keys; Dates compare getTime. I will not JSON.stringify. Map and Set compare size and SameValueZero keys, and object keys need a scan. Holes in arrays read as undefined in an index loop, so a hole equals an explicit undefined here.”
Further reading
Related on this site
- Shallow vs deep equality
- Object.is vs ===
- Deep vs shallow copy
- Structured clone
- Map and Set
- Implement JSON.stringify
- Get and set nested object paths
Related guides
- Deep Clone ImplementationInterview deep clone: JSON is a lossy subset, structuredClone is the host, and recursion plus a WeakMap is how you survive cycles.
- Flatten and Unflatten ObjectsTurn nested objects into dot-path keys and back. Arrays vs objects, numeric keys, and why round-tripping `a.b[0]` is a product decision.
- Get and Set Nested Object PathsImplement get(obj, path, fallback) and set(obj, path, value) for dot paths. Missing segments, non-object middles, and mutating vs cloning.
- Object.assign PolyfillCopy enumerable own properties onto a target. Getters that run, symbols, null/undefined sources skipped, and the mutation-in-place return value.
- Object.entries and fromEntriesRound-trip own enumerable string keys with Object.entries and Object.fromEntries. Not a Map, not symbols, and later duplicate keys win.