Implement deep equal
Deep equal compares two values structurally: primitives by value, objects and arrays by recursively comparing their contents. Compare key counts first, then recurse. The detail that catches people is that an array must never be considered equal to an object, however well their keys line up.
The question
Often framed as "write the assertion library function". The edge cases are the question, not
the recursion. Does [1, 2] equal { 0: 1, 1: 2 }? It must not. Does
key order matter? It must not. And what about NaN, and +0 against
-0?
The solution
Object.is rather than === as the base case, which handles
NaN and the two zeroes correctly for free.
function deepEqual(valueA, valueB) {
if (Object.is(valueA, valueB)) return true;
if (typeof valueA !== "object" || typeof valueB !== "object") return false;
if (valueA === null || valueB === null) return false;
// An array is never equal to a plain object, however matching the keys look.
if (Array.isArray(valueA) !== Array.isArray(valueB)) return false;
const keysA = Object.keys(valueA);
const keysB = Object.keys(valueB);
if (keysA.length !== keysB.length) return false;
// Key order is irrelevant, so compare by lookup rather than by position.
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(valueB, key) && deepEqual(valueA[key], valueB[key]),
);
}
How it works
Object.is handles the awkward primitives: NaN equals itself under it
but not under ===, and the two zeroes stay distinct. The
Array.isArray mismatch check is what stops [1, 2] matching
{ 0: 1, 1: 2 }, since both have the same keys and the same values. Key counts get
compared before contents, which catches the extra-key case cheaply, and lookup is by key rather
than by position so key order doesn’t matter.
The tests it has to pass
This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 5 cases Practice Pad runs against your solution, and the implementation above passes all of them.
✓ compares flat values and arrays by value
expectEqual(
[deepEqual(1, 1), deepEqual("a", "a"), deepEqual([1, 2], [1, 2]), deepEqual([1, 2], [1, 3])],
[true, true, true, false],
);
✓ treats matching nested objects and arrays as equal
expectEqual(
deepEqual(
{ user: { id: 1, tags: ["admin", "editor"] } },
{ user: { id: 1, tags: ["admin", "editor"] } },
),
true,
);
expectEqual(deepEqual([1, { id: 2 }], [1, { id: 3 }]), false);
✓ never equates an array with an object of matching keys
expectEqual(deepEqual([1, 2], { 0: 1, 1: 2 }), false);
expectEqual(deepEqual({ a: { b: {} } }, { a: { b: [] } }), false);
✓ ignores object key order
expectEqual(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }), true);
✓ compares array lengths
expectEqual([deepEqual([1, 2, 3], [1, 2]), deepEqual([1, 2], [1, 2])], [false, true]);
Edge cases to watch out for
Not distinguishing arrays from objects
Object.keys([1, 2]) is ["0", "1"], identical to Object.keys({ 0: 1, 1: 2 }). Without an explicit Array.isArray comparison the two compare equal, which the first medium test checks.
Using === as the base case
NaN === NaN is false, so any structure containing NaN never equals itself. Object.is fixes it in one character.
Comparing only the first object's keys
If b has extra keys, iterating a's keys alone reports equal. Comparing key counts first closes the gap.
Ignoring Date, Map, Set and RegExp
All of them have no own enumerable keys, so a naive implementation reports every Date equal to every other Date. Real deep-equal implementations special-case them, and mentioning it unprompted is a good signal.
Follow up questions
How would you report where two objects differ?
Thread a path through the recursion, appending the key or index at each level, and return the first mismatching path rather than a bare false. That’s the difference between an assertion that says "expected true, got false" and one that says user.tags[1], which is most of the value in a test library.
Why use Object.is instead of ===?
Two cases. NaN === NaN is false, so anything containing NaN would never equal itself. And +0 === -0 is true, which is usually wrong for a structural comparison. Object.is gets both right.
How do you handle circular references in deep equal?
Track the pairs already being compared, usually in a Map from one side to the other. If the same pair comes round again, treat it as equal, since the only way to disprove it would be to recurse forever.
How does deep equal handle Dates and Maps?
A naive implementation gets them wrong, because none of them expose their contents as own enumerable keys, so they all look like empty objects. Compare Dates by getTime(), RegExps by source and flags, and Maps and Sets by size and contents.
Practise this with the tests running
Practice Pad runs these 5 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.
Download for macOS Free. Requires macOS 12 or later on Apple Silicon.