=== 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.
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.
Write deepEqual(a, b). It returns true when the two values have the same structure and contents, and false otherwise.
Primitives compare with Object.is, 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, in any order, with deeply equal values. Arrays also need the same length. So an array never equals an object with the same keys, and an array with a hole doesn't equal one holding undefined there.
Compare dates by their time, and regular expressions by source and flags.
Two Maps are equal when they have the same size and every key of one is in the other (as has finds it) with a deeply equal value. Two Sets are equal when they have the same size and every member of one is in the other, as has finds it.
Values can contain cycles: give an answer without recursing forever.
deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }); // true
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true
deepEqual(new Map([["k", 1]]), new Map([["k", 2]])); // false
deepEqual([1, 2], { 0: 1, 1: 2 }); // false
expect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);
expect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 3 }] })).toBe(false);
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false);
expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true);
expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false);
expect(deepEqual({ a: undefined }, { b: undefined })).toBe(false);
expect(deepEqual({ a: undefined }, {})).toBe(false);
expect(deepEqual(NaN, NaN)).toBe(true);
expect(deepEqual(0, -0)).toBe(false);
expect(deepEqual(1, "1")).toBe(false);
expect(deepEqual(null, null)).toBe(true);
expect(deepEqual(null, {})).toBe(false);
expect(deepEqual({}, null)).toBe(false);
expect(deepEqual(undefined, null)).toBe(false);
expect(deepEqual([1, 2], { 0: 1, 1: 2 })).toBe(false);
expect(deepEqual([], {})).toBe(false);
expect(deepEqual([, 1], [undefined, 1])).toBe(false);
expect(deepEqual([, 1], [, 1])).toBe(true);
expect(deepEqual([], [,])).toBe(false);
expect(deepEqual([1], [1, ,])).toBe(false);
expect(deepEqual(new Date(0), new Date(0))).toBe(true);
expect(deepEqual(new Date(0), new Date(1))).toBe(false);
expect(deepEqual(/a/g, /a/g)).toBe(true);
expect(deepEqual(/a/g, /a/i)).toBe(false);
expect(deepEqual(/a/, /b/)).toBe(false);
expect(deepEqual(new Map([["k", { x: 1 }]]), new Map([["k", { x: 1 }]]))).toBe(true);
expect(deepEqual(new Map([["k", 1]]), new Map([["k", 2]]))).toBe(false);
expect(deepEqual(new Map([["a", 1]]), new Map([["b", 1]]))).toBe(false);
expect(deepEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true);
expect(deepEqual(new Set([1]), new Set([2]))).toBe(false);
expect(deepEqual(new Set([1]), new Set([1, 2]))).toBe(false);
const a = { name: "x" };
a.self = a;
const b = { name: "x" };
b.self = b;
const c = { name: "y" };
c.self = c;
expect(deepEqual(a, b)).toBe(true);
expect(deepEqual(a, c)).toBe(false);
const list = [1];
list.push(list);
const other = [1];
other.push(other);
expect(deepEqual(list, other)).toBe(true);
class Point {
constructor() {
this.x = 1;
}
}
expect(deepEqual(new Point(), { x: 1 })).toBe(false);
expect(deepEqual(new Point(), new Point())).toBe(true);
const bare = Object.create(null);
bare.x = 1;
expect(deepEqual(bare, { x: 1 })).toBe(false);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Deep equal. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.