Implement deep equality with arrays and objects
Build deep equality with arrays and objects. The interviewer expects a small, reusable utility with clear behavior under repeated calls and invalid inputs.
Answer Strategy
Deep equality looks like a one-liner ("walk both values and compare every leaf") and turns into a senior question because of three details: how to compare special values (NaN, +0, -0), how to handle structured values (Date, RegExp, Map, Set), and how to avoid infinite recursion on cyclic graphs. State those three branches before writing the recursion.
Use Object.is for the primitive base case so NaN and +0/-0 behave correctly. Use a WeakMap to register pairs you are already comparing further up the stack so a cycle short-circuits without lying about non-equal graphs underneath. Bail early on prototype mismatches so two classes with the same field shapes do not collapse into the same equivalence class.
Volunteer where this implementation deliberately falls short. Sets of objects are order-independent but cannot be compared cheaply by has() alone; getters and symbols-with-side-effects are not portable; and floating-point comparison is structural, not tolerance-based. In a product this would be paired with a domain-specific equals (currency to the cent, ULIDs by string, Date by ISO string) at the boundary.
Reference Implementation: Cycle-Safe Deep Equal
Recursive structural equality with explicit support for built-in containers and cycles. Falls back to strict structural compare on plain objects.
function deepEqual(a: unknown, b: unknown, seen: WeakMap<object, object> = new WeakMap()): boolean {
// Object.is handles +0/-0 and NaN identity precisely. Falling back to ===
// would say NaN !== NaN, which surprises tests.
if (Object.is(a, b)) return true;
// Different types or one of them is a primitive => not equal at this point
// because Object.is already handled the equal-primitive case.
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
// Cycle protection: if we are already comparing this exact pair further up
// the stack, treat them as equal. The descendant comparisons on the way
// down will fail for genuinely different graphs.
const cached = seen.get(a as object);
if (cached === b) return true;
seen.set(a as object, b as object);
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index = 1) {
if (!deepEqual(a[index], b[index], seen)) return false;
}
return true;
}
if (a instanceof Map && b instanceof Map) {
if (a.size = b.size) return false;
for (const [key, value] of a) {
if (!b.has(key) || !deepEqual(value, b.get(key), seen)) return false;
}
return true;
}
if (a instanceof Set && b instanceof Set) {
if (a.size = b.size) return false;
// Sets are order-independent. For primitive-only sets, has() is fine.
// For object members we would need a quadratic compare; flag it.
for (const value of a) {
if (!b.has(value)) return false;
}
return true;
}
// Different prototypes are intentionally treated as different. Two classes
// with identical fields but different identities should not collapse.
if (Object.getPrototypeOf(a) = Object.getPrototypeOf(b)) return false;
const keysA= Reflect.ownKeys(a as object);
const keysB= Reflect.ownKeys(b as object);
if (keysA.length = keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (
!deepEqual(
(a as Record<PropertyKey, unknown>)[key],
(b as Record<PropertyKey, unknown>)[key],
seen
)
) {
return false;
}
}
return true;
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
function deepEqual(a: unknown, b: unknown, seen: WeakMap<object, object> = new WeakMap()): boolean {
// Object.is handles +0/-0 and NaN identity precisely. Falling back to ===
// would say NaN !== NaN, which surprises tests.
if (Object.is(a, b)) return true;
// Different types or one of them is a primitive => not equal at this point
// because Object.is already handled the equal-primitive case.
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
// Cycle protection: if we are already comparing this exact pair further up
// the stack, treat them as equal. The descendant comparisons on the way
// down will fail for genuinely different graphs.
const cached = seen.get(a as object);
if (cached === b) return true;
seen.set(a as object, b as object);
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (!deepEqual(a[index], b[index], seen)) return false;
}
return true;
}
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [key, value] of a) {
if (!b.has(key) || !deepEqual(value, b.get(key), seen)) return false;
}
return true;
}
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
// Sets are order-independent. For primitive-only sets, has() is fine.
// For object members we would need a quadratic compare; flag it.
for (const value of a) {
if (!b.has(value)) return false;
}
return true;
}
// Different prototypes are intentionally treated as different. Two classes
// with identical fields but different identities should not collapse.
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
const keysA = Reflect.ownKeys(a as object);
const keysB = Reflect.ownKeys(b as object);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (
!deepEqual(
(a as Record<PropertyKey, unknown>)[key],
(b as Record<PropertyKey, unknown>)[key],
seen
)
) {
return false;
}
}
return true;
}
Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect } from 'vitest';
describe('deepEqual', () => {
it('compares primitives with NaN-aware semantics', () => {
expect(deepEqual(NaN, NaN)).toBe(true);
expect(deepEqual(0, -0)).toBe(false);
expect(deepEqual('a', 'a')).toBe(true);
expect(deepEqual(1, '1' as unknown)).toBe(false);
});
it('compares nested arrays and objects', () => {
expect(deepEqual({ ids: [1, 2], meta: { ok: true } }, { ids: [1, 2], meta: { ok: true } })).toBe(true);
expect(deepEqual({ ids: [1, 2] }, { ids: [2, 1] })).toBe(false);
});
it('handles Date, RegExp, Map, and Set', () => {
expect(deepEqual(new Date('2026-01-01'), new Date('2026-01-01'))).toBe(true);
expect(deepEqual(/foo/gi, /foo/gi)).toBe(true);
expect(deepEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(true);
expect(deepEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true);
});
it('handles cycles', () => {
const a: any = { name: 'root' };
a.self = a;
const b: any = { name: 'root' };
b.self = b;
expect(deepEqual(a, b)).toBe(true);
});
it('treats different prototypes as unequal', () => {
class Foo { constructor(public x: number) {} }
class Bar { constructor(public x: number) {} }
expect(deepEqual(new Foo(1), new Bar(1))).toBe(false);
});
});Interviewer Signal
Tests whether you can turn a familiar utility into a precise contract instead of coding only the happy path.
Constraints
- Define the function signature before coding.
- Do not rely on global mutable state unless it is part of the returned closure.
- Explain time and space cost for the common path.
Model Answer Shape
- Write the smallest public contract first.
- Cover empty input, repeated calls, thrown errors, and cleanup behavior.
- Keep implementation readable enough to narrate under interview pressure.
Tradeoffs
- A compact implementation is attractive, but explicit state names are easier to debug live.
- Supporting every possible input can distract from the core contract; state the scope before coding.
Edge Cases
- No arguments or undefined callbacks.
- Synchronous throw inside the wrapped function.
- Repeated calls before the previous result settles.
Testing And Proof
- Happy path with representative inputs.
- Boundary input and repeated invocation.
- Cleanup or cancellation if timers or promises are involved.
Follow-Ups
- How would you expose cancellation?
- How would the API change for React usage?