Kata: deepEqual
Same type, same key set, equal values: count the keys, check each is b's own, then recurse.
Predict first
Before you read: predict
ExamHall's autosave sends a draft only when it differs from the last saved copy. On the same saved copy, one candidate answers q7 for the first time and another candidate clears q3. The team's kata answer and the autosave step:
What does it print?
export function deepEqual(a, b) { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; return Object.keys(a).every((key) => deepEqual(a[key], b[key]));} // The autosave step:const autosave = (lastSaved, draft) => (deepEqual(lastSaved, draft) ? 'skipped' : 'sent');const saved = { q1: 'B', q3: 'A' };const answered = autosave(saved, { q1: 'B', q3: 'A', q7: 'C' });const cleared = autosave(saved, { q1: 'B' });console.log(`answer q7: ${answered} · clear q3: ${cleared}`);The 30-second version
A kata deepEqual treats primitives as equal by SameValueZero, so NaN equals NaN and 0 equals -0. It rejects null and mismatched types early and compares Dates by their time. An array never equals a plain object. Objects need one key set (equal counts, every key b's own), with each value equal by recursion.
Same type, same key set, equal values: count the keys, check each is b's own, then recurse.
How it works
Step 1 of 12
Date? Map, Set, class instances? Cycles? Should 0 equal -0?Analogy: A hedge maze checked against its plan (maze building)
A country house replants its hedge maze from the original plan. Before it opens, the keeper walks the new maze with the plan in hand.
| In the maze | In the kata |
|---|---|
| at each junction she reads the plan's paths and walks each one in the new maze | the loop over a's keys (step 6) |
| she counts both junctions' paths first: a path only the new maze has is on no list she reads | the count check (step 6) |
| a path planted shut on purpose is not the same as a path that is missing | an own key holding undefined against a missing key (step 7) |
| two planting records match when the dates written on them match, not the sheets | Dates by getTime() (step 4) |
| a numbered row of hedge bays is not a heap of bays with numbers tied on | an array against a plain object (step 5) |
| her tick sheet lists, for each plan junction, every new junction she walked it against; a loop that brings back a ticked pair ends there | the seen pairs (steps 9–10) |
In real terms: deepEqual walks two graphs side by side, and a mismatch anywhere, in a type, a key or a value, ends the walk with false.
Breaks down when: a keeper spots an extra path at a glance; code only ever sees the keys it is asked to walk.
Contrasting case: a keeper with one box per plan junction, rubbed out whenever it faces another new junction, loses the earlier pair; round two loops of different lengths she never stops (steps 9–10).
Examples, run for real
4.1 ExamHall EXH-3530: an autosave that never rests
ExamHall's autosave compared the draft with the last saved copy using a draft whose primitive check was if (a === b) return true;. A numeric answer left blank is stored as parseFloat(''), which is NaN, and the autosave ran three times with nothing changed (EXH-3530). MR !3830 replaced the compare with the reference answer and added the 13-test list. The list also runs on two common mutants: M1 checks primitives with === alone, and M2 with Object.is.
4.1 EXH-3530 the M1 draft, 3 autosave ticks with nothing changed (q4 left blank): 3 saves sent
4.1 MR !3830 the reference, the same 3 ticks: 0 saves sent
4.1 reference.mjs 13 of 13 pass
4.1 M1, === as the primitive check 12 of 13 pass · red: T2 "NaN equals NaN"
4.1 M2, Object.is as the primitive check 12 of 13 pass · red: T3 "0 equals -0"
4.1 T13, a 1-cycle against a 2-cycle, both orders: reference.mjs true, true · node:util isDeepStrictEqual false, false
M1 never finds NaN equal to itself, so every tick sends the same draft again (step 2). M2 fixes NaN but splits 0 from -0, which a calculation such as Math.round(-0.4) produces (step 2). T13 picks a contract: two cycles of different lengths that walk alike are equal, where the built-in says false (step 11).
4.2 Ticketing: one hold, three later versions
A box office compares a seat hold with later versions of itself, using the reference:
4.2 the same hold, keys written in another order: true
4.2 a third seat added: false
4.2 the expiry moved by one minute: false
Key order is not part of the key set (step 6), an array's added seat is one more key (step 8), and the expiry compares by its time, not by the Date object (step 4).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office's reschedule check emails ticket holders when a show's record changes. The candidate's answer so far, and the check, run when an organiser reloads a show and when an organiser moves a show by a day:
What does it print?
export function deepEqual(a, b) { if (a === b || (a !== a && b !== b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (Array.isArray(a) !== Array.isArray(b)) return false; const keys = Object.keys(a); return keys.length === Object.keys(b).length && keys.every((key) => deepEqual(a[key], b[key]));} // The reschedule check:const show = { id: 'EV-7', doors: new Date('2026-11-20T19:00:00Z') };const reloaded = { id: 'EV-7', doors: new Date('2026-11-20T19:00:00Z') };const moved = { id: 'EV-7', doors: new Date('2026-11-21T19:00:00Z') };console.log(`reloaded: ${deepEqual(show, reloaded)} · moved a day: ${deepEqual(show, moved)}`);Question 2 of 8
A box office compares a basket with its saved copy before it re-prices it. The candidate's answer is complete apart from the loop's one line:
The team's list has 10 core tests (T1–T10 of step 12; the cycle follow-up comes later).
Which line passes all 10?
export function deepEqual(a, b) { if (a === b || (a !== a && b !== b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && deepEqual(a.getTime(), b.getTime()); } if (Array.isArray(a) !== Array.isArray(b)) return false; const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; for (const key of keys) { /* the loop line */ } return true;}Question 3 of 8
A box office merges baskets from three front ends. The web app sends seats as an array, the kiosk as an object keyed by position, and the gate scanner as an array-like object. The candidate's answer so far:
What does it print?
export function deepEqual(a, b) { if (a === b || (a !== a && b !== b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && deepEqual(a.getTime(), b.getTime()); } const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; for (const key of keys) { if (!Object.hasOwn(b, key) || !deepEqual(a[key], b[key])) return false; } return true;} // The merge check:const web = { seats: ['K12', 'K13'] };const kiosk = { seats: { 0: 'K12', 1: 'K13' } };const scanner = { seats: { 0: 'K12', length: 1 } };console.log(`kiosk: ${deepEqual(web, kiosk)} · scanner: ${deepEqual({ seats: ['K12'] }, scanner)}`);Question 4 of 8
A box office's seat map links each seat to its section and each section back to the map. The interviewer's follow-up: support cycles. The candidate passes a seen map down and has one line left to write:
The list now has 13 tests (step 12).
Which line passes all 13?
export function deepEqual(a, b, seen = new Map()) { if (a === b || (a !== a && b !== b)) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && deepEqual(a.getTime(), b.getTime()); } if (Array.isArray(a) !== Array.isArray(b)) return false; /* the cycle line */ const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; for (const key of keys) { if (!Object.hasOwn(b, key) || !deepEqual(a[key], b[key], seen)) return false; } return true;}Question 5 of 8
the interviewer says: "Implement deepEqual(a, b) for primitives, plain objects, arrays and Dates. NaN equals NaN, and 0 equals -0. Then support cycles." You have 15 minutes and a plain editor, and ExamHall's autosave is the use case.
Before you type: say your test list out loud.
Name at least eight tests, each with its inputs and what it expects.
Question 6 of 8
the interviewer says: "Implement deepEqual(a, b) for primitives, plain objects, arrays and Dates. NaN equals NaN, and 0 equals -0. Then support cycles." You have 15 minutes and a plain editor, and ExamHall's autosave is the use case.
Now write it, in 15 minutes, from a blank file that exports deepEqual.
Question 7 of 8
A ticket's saved record holds transfer: null while no transfer is pending. A fan opens the transfer form, and its draft holds transfer: {}. The screen warns about unsaved changes with this answer:
What does it print?
export function deepEqual(a, b) { if (a === b || (a !== a && b !== b)) return true; if (typeof a !== 'object' || typeof b !== 'object') return false; if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && deepEqual(a.getTime(), b.getTime()); } if (Array.isArray(a) !== Array.isArray(b)) return false; const keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; for (const key of keys) { if (!Object.hasOwn(b, key) || !deepEqual(a[key], b[key])) return false; } return true;} // The screen's check:const saved = { ticket: 'T-88', transfer: null };const draft = { ticket: 'T-88', transfer: {} };let result;try { result = deepEqual(saved, draft);} catch (err) { result = err.name;}console.log(`blank form: ${result}`);Question 8 of 8
A payments ledger reconciles its balances with the bank's export; deepEqual is the kata's reference answer. One export lists acct-2 at 450, and another failed to parse its balances:
What does it print?
import { deepEqual } from './deep-equal.mjs'; const ledger = { currency: 'EUR', balances: new Map([['acct-1', 1200], ['acct-2', 300]]) };const bank = { currency: 'EUR', balances: new Map([['acct-1', 1200], ['acct-2', 450]]) };const unparsed = { currency: 'EUR', balances: {} };try { console.log(`different balances: ${deepEqual(ledger, bank)} · unparsed export: ${deepEqual(ledger, unparsed)}`);} catch (err) { console.log(`reconcile stopped: ${err.name}`);}Go deeper
Pro tips
- Run your own answer against the list before you call it done:
KATA_IMPL=./my-deep-equal.mjs node --test kata.test.mjs. When: practising the kata at home, against the same tests the reference passes (step 12). Verify (RAN,tips.mjs):tip 1 KATA_IMPL=./reference.mjs: exit 0 · tests 13 · pass 13 · fail 0 tip 1 KATA_IMPL=<temp>/m2-deep-equal.mjs (Object.is as the primitive check): exit 1 · tests 13 · pass 12 · fail 1 · red: T3 - Compare the key counts first: one line that catches a key only the second object has,
undefinedor not. When: the first object pass, before the loop (step 6). Verify (RAN,tips.mjs): a draft that walks the first object's keys fails T6; one count line fixes it. (The curriculum row's tip, [RAN].)tip 2 a draft that walks the first object's keys only: T6 red; the same draft with the count line: T6 passes
Source
Vendor and guide pages change, so their claims are labelled as of 2026-10-12; every behaviour this lesson grades is executed on Node 24.
- MDN, Equality comparisons and sameness (the curriculum row's first source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness (
mdn-equality.txt): "Strict equality treats NaN as unequal to every other value — including itself.", and same-value-zero "only differs from strict equality by treating NaN as equivalent, and only differs from same-value equality by treating -0 as equivalent to 0." (step 2). As of 2026-10-12. - Node.js documentation,
assert.deepStrictEqual, comparison details (the row's second source), https://nodejs.org/api/assert.html#assertdeepstrictequalactual-expected-message (node-assert.txt): "Primitive values are compared using Object.is().", "Only enumerable "own" properties are considered.", "Object properties are compared unordered." and "<Map> keys and <Set> items are compared unordered." (steps 6, 11). The page fetched documents the current Node line; it keys no item. As of 2026-10-12. - Front End Interview Handbook, JavaScript coding, https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (
feih-javascript-coding.txt, the copykata-oncefetched on 2026-10-12): candidates take "around 10-15 minutes for a basic question" (the time box, step 1), and it advises "writing test cases" (step 12). As of 2026-10-12.
Related lessons
Checked against the tools and docs as of 2026-10-12.