Write deepEqual(a, b) that compares values structurally, including Map, Set, Date, and cyclic objects.
Before you start
What's deep equality?
=== on two objects doesn't look inside them. It asks whether they're the same object, which is called reference equality, or identity. Two objects created separately are never ===, however alike they are. Deep equality, also called structural equality, compares what's inside instead: two values are deeply equal when they have the same shape and the same contents, compared all the way down through nested objects and arrays.
Think of two printed copies of the same book. === asks whether you're holding the very same copy. Deep equality asks whether every page says the same thing.
const a = { name: "Ada", tags: ["math", "code"] };
const b = { name: "Ada", tags: ["math", "code"] };
const c = a;
a === b; // false: two objects, created separately
a === c; // true: c is the same object as a
a.name === b.name; // true: strings compare by value
a.tags === b.tags; // false: two arrays, even with the same contents
| Values | === | Deeply equal? |
|---|---|---|
{ x: 1 } and { x: 1 } | false | Yes |
[1, [2, 3]] and [1, [2, 3]] | false | Yes: the nested arrays match too |
{ x: { y: 1 } } and { x: { y: 2 } } | false | No: they differ one level down |
a and a, one object | true | Yes |
Tests rely on it all the time. In Jest and Vitest, expect(a).toEqual(b) checks deep equality, while expect(a).toBe(b) checks with Object.is, which for objects means the same object. Lodash's isEqual and Node's util.isDeepStrictEqual are other ready-made versions. Deep equality costs more than ===: it may have to visit every value in both.
React uses identity instead: it compares hook dependencies and memo props with Object.is. So a new object with the same contents counts as a change, and an object changed in place doesn't.
What counts as the same contents is a choice each version makes, for things like NaN, dates, Maps, or two objects with different prototypes. That's why a task that asks for deep equality spells out its rules.
What's a circular reference?
A circular reference is a chain of properties that leads from an object back to itself. It can be direct, as in obj.self = obj, or pass through other objects, as when a team points to its lead and the lead points back to the team. There's nothing wrong with such data, and you can use it normally. The trouble is code that follows every property into every nested object: it goes round the loop and never reaches the end.
const team = { name: "Frontend" };
const ada = { name: "Ada", team };
team.lead = ada; // team -> ada -> team -> ...
ada.team.lead.team.lead.name; // "Ada": you can go round as often as you like
JSON.stringify(team); // throws a TypeError
Here's a walk that visits every nested object, starting from team:
| Step | The walk is at | It follows | Which leads to |
|---|---|---|---|
| 1 | team | team.lead | ada |
| 2 | ada | ada.team | team, where it started |
| 3 | team | team.lead | ada again, and so on forever |
JSON.stringify detects the loop and throws a TypeError, since the text would never end. A function you write that copies, compares, or freezes nested objects, calling itself for each one, doesn't stop on its own: it keeps calling itself until the call stack runs out and the engine throws. So code that walks nested objects has to notice when it comes back round to an object it has already reached.
Reaching an object twice doesn't always mean a loop, though. A shared reference is one object reached by two different paths, with no way back to where you started:
const office = { city: "London" };
const staff = { ada: { office }, grace: { office } };
staff.ada.office === staff.grace.office; // true: one object, two paths
JSON.stringify(staff);
// '{"ada":{"office":{"city":"London"}},"grace":{"office":{"city":"London"}}}'
const copy = structuredClone(staff); // the built-in deep copy
copy.ada.office === copy.grace.office; // true: one new office, still shared
copy.ada.office === office; // false
JSON.stringify writes a shared object out once per path, so parsing the text back gives two separate offices. structuredClone keeps the sharing, and it keeps cycles too: copying team gives a new team whose lead points back to the new team.
Cycles are common in real data. A DOM element's parentNode has the element among its children, tree nodes often link to their parent, a doubly linked list points both ways, and globalThis.globalThis is globalThis.
Primitives are equal when Object.is says so: NaN equals NaN, but 0 and -0 differ.
Two objects are equal when they have the same prototype and the same own enumerable string keys, with deeply equal values. Two arrays also need the same length.
Compare Dates by the time they hold, and RegExps by source and flags. Compare Maps by key, matching keys the way a Map itself does, with deeply equal values. Compare Sets by membership.
Don't loop forever on cycles.
function deepEqual(a, b) {
// your code
}
// deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true
// deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true: key order doesn't matter
// deepEqual([1, 2], { 0: 1, 1: 2 }); // false: different prototypes
// deepEqual(new Map([["k", 1]]), new Map([["k", 2]])); // false
// const x = { name: "x" };
// x.self = x;
// const y = { name: "x" };
// y.self = y;
// deepEqual(x, y); // true, without looping forever
Solve it in the editor, with tests
The answer
Write it yourself first. Then compare it with a reference solution and the checks it passes.
Checking your sign-in…