Implement deepEqual(a, b) — a function that returns true when two values look the same all the way down, regardless of whether they share the same reference. Primitives compare by value, arrays and plain objects compare by walking their structure recursively. Read MDN on Object.is and SameValueZero before you start — the question hinges on which equality flavor you pick for primitives.
function deepEqual(a: unknown, b: unknown): boolean;
// true if a and b are structurally equal across nested objects, arrays,
// primitives, and Dates. false otherwise.
deepEqual(1, 1); // true
deepEqual(NaN, NaN); // true — SameValueZero, not ===
deepEqual(0, -0); // true — SameValueZero again
deepEqual(null, undefined); // false — only null equals null
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true — key order doesn't matter
deepEqual([1, 2, 3], [3, 2, 1]); // false — array order does matter
deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true — nested
deepEqual([1, 2], { 0: 1, 1: 2, length: 2 }); // false — array vs object
// Cycles must not blow the stack. Both inputs point at themselves; treat
// them as equal because the structural shape converges.
const a = {}; a.self = a;
const b = {}; b.self = b;
deepEqual(a, b); // true
// Dates compare by their numeric timestamp.
deepEqual(new Date(0), new Date(0)); // true
deepEqual(new Date(0), new Date(1)); // false
NaN === NaN is true, and 0 === -0 is true. This matches how Array.prototype.includes and Map/Set key lookup behave, and it's what most "deep equal" libraries (lodash.isEqual, Jest's toEqual) ship.a === b (or Object.is(a, b)), return true immediately. This both speeds up the common case and handles cycle entry, where both branches eventually arrive at the same pair you've already seen.{ a: 1, b: 2 } and { b: 2, a: 1 } are equal. Iterate one side's own enumerable keys and look each up on the other side; check key-set sizes first to catch the "extra key on one side" case.[1, 2] and [2, 1] are NOT equal. Arrays are positional; compare element-by-element at each index.false, Date vs plain object is false even if the object happens to expose the same .getTime(). Check Array.isArray on both sides; check instanceof Date on both sides.a.self = a) must not infinite-recurse. Use a WeakMap to remember <a, b> pairs already in flight; if you revisit one, assume equal (the recursion will terminate at the next non-shared field).Map, Set, RegExp, typed arrays, Symbol-keyed properties, or class instances with custom equality. Document these as limitations; the "Going further" section covers them.You'll write a function that walks two values in lockstep and decides whether they look the same all the way down — primitives by value, arrays by index, objects by key — while staying safe around NaN, signed zeros, Dates, and self-referencing cycles.
Picture two API responses sitting side by side: one cached from yesterday, one fetched just now. You want to know if anything actually changed before you bother re-rendering. JSON.stringify is the temptation, but it lies about a half-dozen common inputs (we'll see exactly which). === only tells you if the two responses are the same reference — and they aren't, because the new one came off the wire as a fresh object. What you need is a third thing: a function that says "true" when the two trees look indistinguishable, regardless of whether they share memory.
That's deep equality. It sounds like a tidy one-pager, and the recursive core is. But the edges — NaN, +0/-0, null vs undefined, Date vs object, cycles — are where every hand-rolled version goes wrong. The bulk of this solution is making peace with those edges before writing any recursion at all.
There are three layers to keep separate.
One: there are three equality operators in JavaScript already, and they disagree. === says NaN === NaN is false and +0 === -0 is true. Object.is says NaN equals NaN (good) but +0 doesn't equal -0 (bad, for our purposes). The spec ships a third comparison called SameValueZero — used by Array.prototype.includes, by Map and Set key lookup — that says BOTH NaN === NaN and +0 === -0 are true. That's the flavor lodash.isEqual and Jest's toEqual ship, and it's what we'll use too. The "Going further" section discusses when you might pick differently.
Two: at the structural level this is a lockstep tree walk. Both inputs become trees rooted at a and b. We compare the roots; if they match shape, we recurse pairwise into their children — index 0 of a against index 0 of b, key name of a against key name of b. Leaves are primitives (we apply SameValueZero) or Dates (we compare numeric values). Containers are arrays or plain objects (we recurse).
Three: cycles need memory. If a.self === a and b.self === b, the naive recursion never terminates — it follows self forever. The fix is a WeakMap that remembers every <a, b> pair we're currently comparing. The second time we hit a pair we've already started on, we return true immediately; if there's a real mismatch elsewhere in the structure, it will surface at a non-cyclic field. (If there isn't, returning true is correct — two structures that converge to the same in-flight pair really are equal in shape.)
When this question shows up at the whiteboard, two naive answers come out reliably. Both are wrong, and they're wrong in different ways.
JSON.stringifyThe one-liner:
function deepEqualBad(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
It passes for { a: 1, b: 2 } versus { b: 2, a: 1 } (V8 happens to iterate keys in insertion order so the strings often match), and it passes for [1, 2, 3] versus [1, 2, 3]. Then you hit real data and the lies start.
JSON.stringify({ a: undefined }) === JSON.stringify({}); // "{}" === "{}" → true
// → deepEqualBad({ a: undefined }, {}) is true, but they have different keys
A undefined value evaporates during stringification — the key disappears, so an object with { a: undefined } and an object with no a at all stringify to the same "{}". Functions disappear the same way. NaN and Infinity coerce to "null". Date objects become ISO strings, so new Date(0) stringifies to '"1970-01-01T00:00:00.000Z"' — same as a plain string with that value, which is wrong. And the worst case:
const a = {}; a.self = a;
JSON.stringify(a); // TypeError: Converting circular structure to JSON
Any cycle throws. So JSON.stringify is wrong on at least five concrete classes of input, and the failure mode is silent for four of them and a crash for the fifth. Both are bad outcomes for a function that's supposed to return a boolean.
=== at the top level onlyThe other reflex is to "fix" the alias problem with ===:
function deepEqualBad2(a, b) {
return a === b;
}
This is wrong for the entire question:
deepEqualBad2({ a: 1 }, { a: 1 }); // false — different references
deepEqualBad2([1, 2], [1, 2]); // false — different references
=== on objects is reference equality — it asks "are these the same address in memory?" — and any two object literals are different addresses. The whole point of deep equality is to compare structure across reference boundaries. This attempt also botches the primitives: deepEqualBad2(NaN, NaN) is false, because === treats NaN as unequal to itself.
So we need (a) a structural walk, not a single equality check, (b) the right primitive equality flavor (SameValueZero) at the leaves, and (c) cycle safety. None of those drops out of a one-liner.
function deepEqual(a, b, seen = new WeakMap()) {
// Reference identity — covers NaN-NaN via Object.is in a moment, and the
// top-level same-reference case. Also handles cycle entry: once both
// branches reach a previously-seen <a, b> pair, return true to break.
if (Object.is(a, b)) return true;
// SameValueZero quirk: Object.is treats -0 and +0 as DISTINCT; the spec
// we follow says they're equal. Patch the one case Object.is gets "wrong".
if (a === 0 && b === 0) return true;
// Primitives that aren't reference-equal can't be deep-equal — there's no
// structure to recurse into. The null check has to be explicit because
// `typeof null === 'object'`.
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
// Type tags. Array.isArray and Date have to match SYMMETRICALLY — an array
// and an object with the same numeric keys are not deep-equal.
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (a instanceof Date && b instanceof Date) return +a === +b;
if (a instanceof Date || b instanceof Date) return false;
// Cycle detection: if we've seen this exact <a, b> pair before, we're on
// a path that's already in flight — assume equal to terminate. Any real
// mismatch will surface at a non-cyclic field elsewhere in the structure.
if (seen.get(a) === b) return true;
seen.set(a, b);
if (Array.isArray(a)) {
// Length check first — cheap and short-circuits the common mismatch case
// before any recursion.
if (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;
}
// Plain object case. Compare own enumerable keys as a SET (size + membership)
// before recursing into values.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
// hasOwnProperty so an inherited key on b doesn't falsely pass.
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k], seen)) return false;
}
return true;
}
module.exports = { deepEqual };
Six shifts from the naive versions, in the order they appear in the code.
One — Object.is as the first check. This single line buys you three things at once. It returns true when a and b are the same reference (the top-level identity case, AND the cycle-termination case if we re-encounter the same pair). It returns true when both are NaN — exactly the SameValueZero behavior we want. It returns false for everything else interesting, which lets the function fall through to the rest. Using === here would force a separate Number.isNaN(a) && Number.isNaN(b) branch; Object.is folds that into the same check.
Two — the explicit +0/-0 patch. Object.is(+0, -0) is false, but SameValueZero says they're equal. The one-line patch — if (a === 0 && b === 0) return true — fixes that one case without disturbing anything else. (Strict equality === returns true for +0 === -0, so we can lean on it here.) If you skip this line, deepEqual(0, -0) returns false, which fails the test and contradicts how lodash and Jest behave.
Three — primitive bailout with explicit null check. If either side is a non-object after the identity check, they can't be deep-equal — primitives that weren't Object.is-equal a few lines up are genuinely different values. The null check is not optional: typeof null === 'object' is a forty-year-old JavaScript bug we have to work around, otherwise deepEqual(null, {}) would fall through to the recursion branch and crash on Object.keys(null).
Four — symmetric type tags. Array.isArray(a) !== Array.isArray(b) returns false if exactly one side is an array. instanceof Date is checked twice: once for the "both Dates" case (return numeric equality) and once for the "exactly one Date" case (return false). The symmetry matters — if you only check a instanceof Date && b instanceof Date, you'd let Date versus { getTime: () => 0 } fall into the plain-object branch and accidentally pass.
Five — the cycle WeakMap. Before recursing into children, record seen.set(a, b). On the next recursive call, if seen.get(a) === b, the same pair is already in flight — return true. We use WeakMap (not Map) because the keys are objects we don't want to keep alive past the call; once deepEqual returns, the map can be garbage-collected along with its entries. The check seen.get(a) === b (rather than seen.has(a)) handles the case where a is compared against multiple different right-hand sides during recursion — we want the pair, not just the left side.
Six — cheap checks before iteration. Array length is checked before the index loop; key-set size is checked before the key loop. Both are O(1) and rule out the most common mismatch (different shapes) before any recursion. Inside the object loop, hasOwnProperty on b makes sure an inherited prototype key on b doesn't falsely match an own key on a.
Two traces — one for a normal nested input, one for a cyclic pair.
deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] });
deepEqual(rootA, rootB, seen=new WeakMap()). Object.is(rootA, rootB) is false (different references). Neither is 0. Both are objects, neither is null. Neither is an array (yet) — at the top level both are plain objects. Neither is a Date. seen.get(rootA) is undefined, not equal to rootB. Call seen.set(rootA, rootB).rootA → ['a']. rootB has key 'a' (hasOwnProperty true). Recurse: deepEqual([1, { b: 2 }], [1, { b: 2 }], seen).Object.is false (different array references). Both objects, non-null. Array.isArray is true for both. Not Dates. seen.get(arrA) is undefined. seen.set(arrA, arrB). Lengths both 2 — match. Recurse on index 0: deepEqual(1, 1, seen).Object.is(1, 1) is true. Return true. Back in the array loop.deepEqual({ b: 2 }, { b: 2 }, seen). Object.is false. Both objects, non-null. Neither array. Neither Date. seen.get(objA) undefined. seen.set(objA, objB). Keys ['b'] on both sides, length 1 = 1. hasOwnProperty of 'b' on objB is true. Recurse: deepEqual(2, 2, seen).Object.is(2, 2) is true. Return true. Bubble up: object loop completes, returns true. Array loop completes, returns true. Outer object loop completes, returns true. Top-level call returns true.Six recursive calls; four Object.is short-circuits at the leaves; the seen map ends up with three entries (root, array, inner object) but is discarded when the function returns.
const a = { x: 1 }; a.self = a;
const b = { x: 1 }; b.self = b;
deepEqual(a, b);
deepEqual(a, b, seen). Object.is(a, b) is false. Neither is 0. Both objects, non-null. Neither array. Neither Date. seen.get(a) is undefined. seen.set(a, b) — the map now holds <a, b>.Object.keys(a) → ['x', 'self'].'x'. deepEqual(1, 1, seen) → true via Object.is. Good, continue.'self'. Recurse: deepEqual(a, b, seen) (because a.self === a and b.self === b).Object.is(a, b) still false. Not zero. Both objects. Neither array. Neither Date. seen.get(a) === b — YES, we set that in step 1. Return true immediately.true. No infinite recursion.The WeakMap entry is what stops the descent at depth 2. Without it, step 5 would re-enter the object loop, hit self again, and recurse forever until the stack blew up.
If one of the non-cyclic fields had differed — say a.x = 1 and b.x = 2 — the function would have returned false at step 3, before ever reaching the cycle. The cycle handling is only invoked when the structure-modulo-cycles is genuinely the same on both sides.
JSON.stringify symmetry lies on five inputs. undefined values disappear (key dropped), NaN and Infinity coerce to null, functions disappear, Date becomes an ISO string, and cycles throw outright. Any solution that leans on stringification is wrong on four of these silently and crashes on the fifth. Use a recursive walk.typeof null === 'object' is a forty-year-old footgun. Without an explicit null check before the recursion branch, deepEqual(null, {}) falls through to Object.keys(null) and throws TypeError: Cannot convert undefined or null to object. Always handle null before the typeof check.{ a: 1, b: 2 } equals { b: 2, a: 1 } — iterate one side's keys and look each up on the other. But [1, 2] does NOT equal [2, 1] — arrays are positional, compare element by element at each index. Mixing the two (sorting arrays before comparing) destroys real differences.Date(0) should not deep-equal { getTime: () => 0 }, even though both expose the same numeric value. If you only check a instanceof Date && b instanceof Date and forget the a instanceof Date || b instanceof Date companion line, the plain object falls into the recursion branch and matches its own getTime key against the Date's lack thereof — sometimes spuriously passing.seen map are an infinite-recursion bug. a.self = a; b.self = b; deepEqual(a, b) follows self forever and overflows the stack. The seen WeakMap of <left, right> pairs is the only way to terminate. Using Map works functionally but leaks memory across long-lived calls; prefer WeakMap.Object.keys. If a caller does Object.create({ inherited: 'x' }) and adds their own own: 'y', only own is compared. This is usually the right choice (we compare own structure, not class identity), but it's a limitation worth knowing — deepEqual(Object.create({ secret: 1 }), {}) returns true.Object.keys returns only string keys; symbol-keyed entries are invisible to the comparison. If your domain uses symbol keys (well-known symbols like Symbol.iterator or app-specific ones), the function silently treats them as equal — document this.undefined arrays look different to Object.keys. [1, , 3] has keys ['0', '2'] (the hole at index 1 is not an own property), while [1, undefined, 3] has keys ['0', '1', '2']. Our length-based loop visits every index from 0 to length-1, so it treats both the same way (recurses on undefined for both). If you want them distinguished, iterate via Object.keys on arrays too — but that's a design choice; document whichever way you go.Map and Set support. Both are unordered collections, so you can't just zip entries. For Set, check size then, for each item in aSet, search bSet for a deep-equal item — O(n²). For Map, do the same but compare key-value pairs. Map keys can themselves be objects, so the key search is also a recursive deepEqual call. Watch for cycles on both keys and values.RegExp support. Two regexes are structurally equal when they have the same source and the same flags. new RegExp('a', 'g') and new RegExp('a', 'g') are different references but should compare equal. Add an instanceof RegExp branch before the plain-object case.ArrayBuffer. Uint8Array, Float32Array, and friends are array-like but Array.isArray returns false for them. Compare byte-by-byte using their length and indexed access (or Buffer.compare in Node). ArrayBuffer itself needs a DataView wrapper for byte-wise comparison.Symbol.equals or is method on objects, letting class authors define their own structural equality. You'd check typeof a[Symbol.equals] === 'function' and delegate to it before falling into the default branch.lastSeenAt), thread a pick: string[] option through the recursion and skip keys not in the list. The interview version of this is the "shallow diff for an audit log" follow-up — the same recursion, gated by a schema.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement deepEqual(a, b) — a function that returns true when two values look the same all the way down, regardless of whether they share the same reference. Primitives compare by value, arrays and plain objects compare by walking their structure recursively. Read MDN on Object.is and SameValueZero before you start — the question hinges on which equality flavor you pick for primitives.
function deepEqual(a: unknown, b: unknown): boolean;
// true if a and b are structurally equal across nested objects, arrays,
// primitives, and Dates. false otherwise.
deepEqual(1, 1); // true
deepEqual(NaN, NaN); // true — SameValueZero, not ===
deepEqual(0, -0); // true — SameValueZero again
deepEqual(null, undefined); // false — only null equals null
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true — key order doesn't matter
deepEqual([1, 2, 3], [3, 2, 1]); // false — array order does matter
deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true — nested
deepEqual([1, 2], { 0: 1, 1: 2, length: 2 }); // false — array vs object
// Cycles must not blow the stack. Both inputs point at themselves; treat
// them as equal because the structural shape converges.
const a = {}; a.self = a;
const b = {}; b.self = b;
deepEqual(a, b); // true
// Dates compare by their numeric timestamp.
deepEqual(new Date(0), new Date(0)); // true
deepEqual(new Date(0), new Date(1)); // false
NaN === NaN is true, and 0 === -0 is true. This matches how Array.prototype.includes and Map/Set key lookup behave, and it's what most "deep equal" libraries (lodash.isEqual, Jest's toEqual) ship.a === b (or Object.is(a, b)), return true immediately. This both speeds up the common case and handles cycle entry, where both branches eventually arrive at the same pair you've already seen.{ a: 1, b: 2 } and { b: 2, a: 1 } are equal. Iterate one side's own enumerable keys and look each up on the other side; check key-set sizes first to catch the "extra key on one side" case.[1, 2] and [2, 1] are NOT equal. Arrays are positional; compare element-by-element at each index.false, Date vs plain object is false even if the object happens to expose the same .getTime(). Check Array.isArray on both sides; check instanceof Date on both sides.a.self = a) must not infinite-recurse. Use a WeakMap to remember <a, b> pairs already in flight; if you revisit one, assume equal (the recursion will terminate at the next non-shared field).Map, Set, RegExp, typed arrays, Symbol-keyed properties, or class instances with custom equality. Document these as limitations; the "Going further" section covers them.You'll write a function that walks two values in lockstep and decides whether they look the same all the way down — primitives by value, arrays by index, objects by key — while staying safe around NaN, signed zeros, Dates, and self-referencing cycles.
Picture two API responses sitting side by side: one cached from yesterday, one fetched just now. You want to know if anything actually changed before you bother re-rendering. JSON.stringify is the temptation, but it lies about a half-dozen common inputs (we'll see exactly which). === only tells you if the two responses are the same reference — and they aren't, because the new one came off the wire as a fresh object. What you need is a third thing: a function that says "true" when the two trees look indistinguishable, regardless of whether they share memory.
That's deep equality. It sounds like a tidy one-pager, and the recursive core is. But the edges — NaN, +0/-0, null vs undefined, Date vs object, cycles — are where every hand-rolled version goes wrong. The bulk of this solution is making peace with those edges before writing any recursion at all.
There are three layers to keep separate.
One: there are three equality operators in JavaScript already, and they disagree. === says NaN === NaN is false and +0 === -0 is true. Object.is says NaN equals NaN (good) but +0 doesn't equal -0 (bad, for our purposes). The spec ships a third comparison called SameValueZero — used by Array.prototype.includes, by Map and Set key lookup — that says BOTH NaN === NaN and +0 === -0 are true. That's the flavor lodash.isEqual and Jest's toEqual ship, and it's what we'll use too. The "Going further" section discusses when you might pick differently.
Two: at the structural level this is a lockstep tree walk. Both inputs become trees rooted at a and b. We compare the roots; if they match shape, we recurse pairwise into their children — index 0 of a against index 0 of b, key name of a against key name of b. Leaves are primitives (we apply SameValueZero) or Dates (we compare numeric values). Containers are arrays or plain objects (we recurse).
Three: cycles need memory. If a.self === a and b.self === b, the naive recursion never terminates — it follows self forever. The fix is a WeakMap that remembers every <a, b> pair we're currently comparing. The second time we hit a pair we've already started on, we return true immediately; if there's a real mismatch elsewhere in the structure, it will surface at a non-cyclic field. (If there isn't, returning true is correct — two structures that converge to the same in-flight pair really are equal in shape.)
When this question shows up at the whiteboard, two naive answers come out reliably. Both are wrong, and they're wrong in different ways.
JSON.stringifyThe one-liner:
function deepEqualBad(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
It passes for { a: 1, b: 2 } versus { b: 2, a: 1 } (V8 happens to iterate keys in insertion order so the strings often match), and it passes for [1, 2, 3] versus [1, 2, 3]. Then you hit real data and the lies start.
JSON.stringify({ a: undefined }) === JSON.stringify({}); // "{}" === "{}" → true
// → deepEqualBad({ a: undefined }, {}) is true, but they have different keys
A undefined value evaporates during stringification — the key disappears, so an object with { a: undefined } and an object with no a at all stringify to the same "{}". Functions disappear the same way. NaN and Infinity coerce to "null". Date objects become ISO strings, so new Date(0) stringifies to '"1970-01-01T00:00:00.000Z"' — same as a plain string with that value, which is wrong. And the worst case:
const a = {}; a.self = a;
JSON.stringify(a); // TypeError: Converting circular structure to JSON
Any cycle throws. So JSON.stringify is wrong on at least five concrete classes of input, and the failure mode is silent for four of them and a crash for the fifth. Both are bad outcomes for a function that's supposed to return a boolean.
=== at the top level onlyThe other reflex is to "fix" the alias problem with ===:
function deepEqualBad2(a, b) {
return a === b;
}
This is wrong for the entire question:
deepEqualBad2({ a: 1 }, { a: 1 }); // false — different references
deepEqualBad2([1, 2], [1, 2]); // false — different references
=== on objects is reference equality — it asks "are these the same address in memory?" — and any two object literals are different addresses. The whole point of deep equality is to compare structure across reference boundaries. This attempt also botches the primitives: deepEqualBad2(NaN, NaN) is false, because === treats NaN as unequal to itself.
So we need (a) a structural walk, not a single equality check, (b) the right primitive equality flavor (SameValueZero) at the leaves, and (c) cycle safety. None of those drops out of a one-liner.
function deepEqual(a, b, seen = new WeakMap()) {
// Reference identity — covers NaN-NaN via Object.is in a moment, and the
// top-level same-reference case. Also handles cycle entry: once both
// branches reach a previously-seen <a, b> pair, return true to break.
if (Object.is(a, b)) return true;
// SameValueZero quirk: Object.is treats -0 and +0 as DISTINCT; the spec
// we follow says they're equal. Patch the one case Object.is gets "wrong".
if (a === 0 && b === 0) return true;
// Primitives that aren't reference-equal can't be deep-equal — there's no
// structure to recurse into. The null check has to be explicit because
// `typeof null === 'object'`.
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
// Type tags. Array.isArray and Date have to match SYMMETRICALLY — an array
// and an object with the same numeric keys are not deep-equal.
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (a instanceof Date && b instanceof Date) return +a === +b;
if (a instanceof Date || b instanceof Date) return false;
// Cycle detection: if we've seen this exact <a, b> pair before, we're on
// a path that's already in flight — assume equal to terminate. Any real
// mismatch will surface at a non-cyclic field elsewhere in the structure.
if (seen.get(a) === b) return true;
seen.set(a, b);
if (Array.isArray(a)) {
// Length check first — cheap and short-circuits the common mismatch case
// before any recursion.
if (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;
}
// Plain object case. Compare own enumerable keys as a SET (size + membership)
// before recursing into values.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
// hasOwnProperty so an inherited key on b doesn't falsely pass.
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k], seen)) return false;
}
return true;
}
module.exports = { deepEqual };
Six shifts from the naive versions, in the order they appear in the code.
One — Object.is as the first check. This single line buys you three things at once. It returns true when a and b are the same reference (the top-level identity case, AND the cycle-termination case if we re-encounter the same pair). It returns true when both are NaN — exactly the SameValueZero behavior we want. It returns false for everything else interesting, which lets the function fall through to the rest. Using === here would force a separate Number.isNaN(a) && Number.isNaN(b) branch; Object.is folds that into the same check.
Two — the explicit +0/-0 patch. Object.is(+0, -0) is false, but SameValueZero says they're equal. The one-line patch — if (a === 0 && b === 0) return true — fixes that one case without disturbing anything else. (Strict equality === returns true for +0 === -0, so we can lean on it here.) If you skip this line, deepEqual(0, -0) returns false, which fails the test and contradicts how lodash and Jest behave.
Three — primitive bailout with explicit null check. If either side is a non-object after the identity check, they can't be deep-equal — primitives that weren't Object.is-equal a few lines up are genuinely different values. The null check is not optional: typeof null === 'object' is a forty-year-old JavaScript bug we have to work around, otherwise deepEqual(null, {}) would fall through to the recursion branch and crash on Object.keys(null).
Four — symmetric type tags. Array.isArray(a) !== Array.isArray(b) returns false if exactly one side is an array. instanceof Date is checked twice: once for the "both Dates" case (return numeric equality) and once for the "exactly one Date" case (return false). The symmetry matters — if you only check a instanceof Date && b instanceof Date, you'd let Date versus { getTime: () => 0 } fall into the plain-object branch and accidentally pass.
Five — the cycle WeakMap. Before recursing into children, record seen.set(a, b). On the next recursive call, if seen.get(a) === b, the same pair is already in flight — return true. We use WeakMap (not Map) because the keys are objects we don't want to keep alive past the call; once deepEqual returns, the map can be garbage-collected along with its entries. The check seen.get(a) === b (rather than seen.has(a)) handles the case where a is compared against multiple different right-hand sides during recursion — we want the pair, not just the left side.
Six — cheap checks before iteration. Array length is checked before the index loop; key-set size is checked before the key loop. Both are O(1) and rule out the most common mismatch (different shapes) before any recursion. Inside the object loop, hasOwnProperty on b makes sure an inherited prototype key on b doesn't falsely match an own key on a.
Two traces — one for a normal nested input, one for a cyclic pair.
deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] });
deepEqual(rootA, rootB, seen=new WeakMap()). Object.is(rootA, rootB) is false (different references). Neither is 0. Both are objects, neither is null. Neither is an array (yet) — at the top level both are plain objects. Neither is a Date. seen.get(rootA) is undefined, not equal to rootB. Call seen.set(rootA, rootB).rootA → ['a']. rootB has key 'a' (hasOwnProperty true). Recurse: deepEqual([1, { b: 2 }], [1, { b: 2 }], seen).Object.is false (different array references). Both objects, non-null. Array.isArray is true for both. Not Dates. seen.get(arrA) is undefined. seen.set(arrA, arrB). Lengths both 2 — match. Recurse on index 0: deepEqual(1, 1, seen).Object.is(1, 1) is true. Return true. Back in the array loop.deepEqual({ b: 2 }, { b: 2 }, seen). Object.is false. Both objects, non-null. Neither array. Neither Date. seen.get(objA) undefined. seen.set(objA, objB). Keys ['b'] on both sides, length 1 = 1. hasOwnProperty of 'b' on objB is true. Recurse: deepEqual(2, 2, seen).Object.is(2, 2) is true. Return true. Bubble up: object loop completes, returns true. Array loop completes, returns true. Outer object loop completes, returns true. Top-level call returns true.Six recursive calls; four Object.is short-circuits at the leaves; the seen map ends up with three entries (root, array, inner object) but is discarded when the function returns.
const a = { x: 1 }; a.self = a;
const b = { x: 1 }; b.self = b;
deepEqual(a, b);
deepEqual(a, b, seen). Object.is(a, b) is false. Neither is 0. Both objects, non-null. Neither array. Neither Date. seen.get(a) is undefined. seen.set(a, b) — the map now holds <a, b>.Object.keys(a) → ['x', 'self'].'x'. deepEqual(1, 1, seen) → true via Object.is. Good, continue.'self'. Recurse: deepEqual(a, b, seen) (because a.self === a and b.self === b).Object.is(a, b) still false. Not zero. Both objects. Neither array. Neither Date. seen.get(a) === b — YES, we set that in step 1. Return true immediately.true. No infinite recursion.The WeakMap entry is what stops the descent at depth 2. Without it, step 5 would re-enter the object loop, hit self again, and recurse forever until the stack blew up.
If one of the non-cyclic fields had differed — say a.x = 1 and b.x = 2 — the function would have returned false at step 3, before ever reaching the cycle. The cycle handling is only invoked when the structure-modulo-cycles is genuinely the same on both sides.
JSON.stringify symmetry lies on five inputs. undefined values disappear (key dropped), NaN and Infinity coerce to null, functions disappear, Date becomes an ISO string, and cycles throw outright. Any solution that leans on stringification is wrong on four of these silently and crashes on the fifth. Use a recursive walk.typeof null === 'object' is a forty-year-old footgun. Without an explicit null check before the recursion branch, deepEqual(null, {}) falls through to Object.keys(null) and throws TypeError: Cannot convert undefined or null to object. Always handle null before the typeof check.{ a: 1, b: 2 } equals { b: 2, a: 1 } — iterate one side's keys and look each up on the other. But [1, 2] does NOT equal [2, 1] — arrays are positional, compare element by element at each index. Mixing the two (sorting arrays before comparing) destroys real differences.Date(0) should not deep-equal { getTime: () => 0 }, even though both expose the same numeric value. If you only check a instanceof Date && b instanceof Date and forget the a instanceof Date || b instanceof Date companion line, the plain object falls into the recursion branch and matches its own getTime key against the Date's lack thereof — sometimes spuriously passing.seen map are an infinite-recursion bug. a.self = a; b.self = b; deepEqual(a, b) follows self forever and overflows the stack. The seen WeakMap of <left, right> pairs is the only way to terminate. Using Map works functionally but leaks memory across long-lived calls; prefer WeakMap.Object.keys. If a caller does Object.create({ inherited: 'x' }) and adds their own own: 'y', only own is compared. This is usually the right choice (we compare own structure, not class identity), but it's a limitation worth knowing — deepEqual(Object.create({ secret: 1 }), {}) returns true.Object.keys returns only string keys; symbol-keyed entries are invisible to the comparison. If your domain uses symbol keys (well-known symbols like Symbol.iterator or app-specific ones), the function silently treats them as equal — document this.undefined arrays look different to Object.keys. [1, , 3] has keys ['0', '2'] (the hole at index 1 is not an own property), while [1, undefined, 3] has keys ['0', '1', '2']. Our length-based loop visits every index from 0 to length-1, so it treats both the same way (recurses on undefined for both). If you want them distinguished, iterate via Object.keys on arrays too — but that's a design choice; document whichever way you go.Map and Set support. Both are unordered collections, so you can't just zip entries. For Set, check size then, for each item in aSet, search bSet for a deep-equal item — O(n²). For Map, do the same but compare key-value pairs. Map keys can themselves be objects, so the key search is also a recursive deepEqual call. Watch for cycles on both keys and values.RegExp support. Two regexes are structurally equal when they have the same source and the same flags. new RegExp('a', 'g') and new RegExp('a', 'g') are different references but should compare equal. Add an instanceof RegExp branch before the plain-object case.ArrayBuffer. Uint8Array, Float32Array, and friends are array-like but Array.isArray returns false for them. Compare byte-by-byte using their length and indexed access (or Buffer.compare in Node). ArrayBuffer itself needs a DataView wrapper for byte-wise comparison.Symbol.equals or is method on objects, letting class authors define their own structural equality. You'd check typeof a[Symbol.equals] === 'function' and delegate to it before falling into the default branch.lastSeenAt), thread a pick: string[] option through the recursion and skip keys not in the list. The interview version of this is the "shallow diff for an audit log" follow-up — the same recursion, gated by a schema.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.