ESC

Type to search the knowledge base.

Deep Equality in JavaScript

Compare values structurally: primitives, NaN, arrays, plain objects, dates, and the cycle that will infinite-loop a naive recursion.

advanced6 min read
  • 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.is is the leaf check. That makes NaN equal NaN and +0 unequal -0.
  • Arrays compare length and index slots. Plain objects compare own enumerable keys.
  • Date compares getTime(), not identity.
  • Cycles need a visited pair (a previously matched b), not a single seen set of objects.
  • JSON.stringify is not deep equal. Map and Set are 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:

  1. Require both sides to be Map (or both Set).
  2. Compare .size.
  3. For each [key, value] of a, find a key on b that is deeply equal (or Object.is for 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

  1. typeof null. After Object.is fails, typeof null === 'object'. The a === null || b === null guard stops you from walking null.
  2. === for leaves. Loses NaN. Use Object.is.
  3. JSON.stringify. Key order, undefined, NaN, functions, Map, cycles. Not a fallback.
  4. Comparing Date with ==. Coerces to a number, but Date vs timestamp mixed types should be false if you already required instanceof Date on both.
  5. Mutating seen without pairing. seen.set(a, true) cannot tell which b you 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 guides