Index

Types, coercion and equality

Nobody is asked this to find out whether they know that '5' + 3 is '53'. They are asked to find out whether the answer is "because plus prefers strings and minus has no string meaning, so it converts" or "because JavaScript is weird". The second answer is the one that fails. There are three or four rules underneath every puzzle here, they are short, and once you can name them the trick questions stop being trick questions.

Seven primitives and one everything-else

string, number, boolean, undefined, null, symbol, bigint - and object, which covers arrays, functions, dates, maps, regexes and everything you ever wrote. Primitives are compared and copied by value; objects by reference. That single split explains why {} === {} is false and why passing an object to a function lets the function change it.

typeof reports eight strings, and two of them are famous: typeof null is "object" and typeof function () {} is "function" even though functions are objects. The first is a bug kept for compatibility; the second is a deliberate convenience.

What `==` actually does

=== compares type and value with no conversion, and that is the whole algorithm. == runs a conversion procedure first, and the procedure is short enough to recite. Being able to recite it is what the question is for.

Same type
Fall through to ===. == is only interesting when the types differ.
null and undefined
null == undefined is true, and neither is == to anything else - not 0, not '', not false. This is a hardcoded special case, and it is the one genuinely useful use of ==: x == null tests "null or undefined" in one comparison.
number and string
The string is converted to a number. So '5' == 5 is true and '' == 0 is true, because Number('') is 0.
boolean and anything
The BOOLEAN is converted to a number first - true becomes 1, false becomes 0 - and then the comparison starts over. This is why '1' == true is true and 'yes' == true is false: nothing is ever converted to a boolean here, which is the opposite of what people assume.
object and primitive
The object is converted to a primitive via ToPrimitive - valueOf() first for most objects, toString() first for Date - and then the comparison starts over. This is where the array puzzles come from: [] becomes '' and [5] becomes '5'.
NaN
Not equal to anything, including itself, under both == and ===. Object.is(NaN, NaN) is the only built-in comparison that says true.

Questions

Answer each one yourself before you open it.

  1. 01What are the primitive types in JavaScript?

    Seven: string, number, boolean, undefined, null, symbol and bigint. Everything else is an object - arrays, functions, dates, maps included.

    The useful consequence, and the one to say next: primitives are immutable and compared by value, objects are compared by reference. 'a' === 'a' is true; [] === [] is false. Almost every equality question on this page is that split showing up somewhere.

  2. 02Why is typeof null "object"?

    It's a bug from the first implementation - values were tagged by a few low bits, objects were tag zero, and the null pointer was all zeros, so it read as an object. It was proposed for a fix and rejected because too much code depends on it.

    The practical version is more useful than the history: it means typeof x === 'object' is not a test for an object. To check for a real object you need typeof x === 'object' && x !== null, and that null guard is the point of the question.

  3. 03undefined vs null?

    undefined is the language's absence - a variable declared but not assigned, a missing property, a parameter you didn't pass, a function with no return. null is your absence: a value someone deliberately set to mean "empty".

    The distinction is a convention, not enforcement, which is why teams pick one. The mechanical differences matter though: typeof null is "object" while typeof undefined is "undefined", Number(null) is 0 while Number(undefined) is NaN, and default parameters fire for undefined but NOT for null.

    Follow-up Are they equal?

    null == undefined is true, null === undefined is false. That's a special case in the == algorithm, and it's the reason x == null is the one loose comparison worth writing.

  4. 04What is NaN?

    The number you get from a numeric operation that has no numeric answer - 0/0, Number('abc'), undefined + 1. It's of type number, which is the part people find funny.

    Follow-up Why is NaN !== NaN?

    Because it comes from the IEEE 754 floating-point spec, where NaN is unordered - it isn't less than, greater than, or equal to anything, including itself. It represents "not a valid number", and two invalid results aren't the same result.

  5. 05How do you test whether something is NaN?

    Number.isNaN(x). The global isNaN(x) coerces its argument to a number first, so isNaN('hello') is true - which is almost never what you meant.

    Number.isNaN returns true for exactly one value: the actual NaN. The trick everyone mentions is x !== x, which works for the same reason and is what Number.isNaN does internally. Use the named function; the trick is a comment away from being deleted by someone who thinks it's a typo.

  6. 06== vs ===?

    === compares type and value with no conversion. == converts the operands to a common type first, using a fixed algorithm, and then compares. Use === unless you specifically want the null-or-undefined check.

    Follow-up What conversions does == do?

    Same type, compare directly. null and undefined are equal to each other and nothing else. String versus number, the string becomes a number. Boolean versus anything, the boolean becomes a number first. Object versus primitive, the object goes through ToPrimitive. NaN is equal to nothing.

  7. 07Is == ever the right choice?

    One case: x == null, which is true for exactly null and undefined and nothing else. It's shorter and less noisy than x === null || x === undefined, and it's a common lint exception for that reason.

  8. 08What are the falsy values?

    Exactly eight, and it's a closed list: false, 0, -0, 0n, '', null, undefined, NaN. Everything else is truthy - including '0', 'false', [] and {}.

    Say "it's a closed list of eight" and then list them. Empty array and empty object being truthy is the one that bites: if (arr) is true for an empty array, so you want arr.length.

  9. 09Implicit vs explicit coercion?

    Explicit is you asking - Number(x), String(x), Boolean(x). Implicit is an operator doing it because the types didn't line up - '5' - 3, if (x), x + ''.

    The interviewing point isn't that implicit is bad. It's that implicit coercion is the same conversion functions running, just invisibly, so if you know what Number('') returns you already know why '' == 0.

  10. 10Why is '5' + 3 a string but '5' - 3 a number?

    + is overloaded: if either side is a string after ToPrimitive, it concatenates. - has no string meaning at all, so it converts both sides to numbers.

    That's the whole rule, and it generalises: *, /, % and - are numeric-only and always coerce; + is the only arithmetic operator that prefers strings. Which is why '5' * '2' is 10 and '5' + '2' is '52'.

  11. 11What is ToPrimitive?

    The conversion an object goes through when it meets an operator that wants a primitive. It tries valueOf() then toString() for most objects, and toString() first for Date, taking the first result that is a primitive.

    It's what makes the array puzzles work. [].valueOf() returns the array itself, which isn't primitive, so toString() runs and gives ''. [1,2].toString() gives '1,2'. Once you know that, [] == 0 and [1,2] + [3] stop being mysterious.

  12. 12Object.is vs ===?

    Identical except for two values: Object.is(NaN, NaN) is true where === says false, and Object.is(0, -0) is false where === says true.

    It's called SameValue in the spec, and it's what React uses to decide whether state changed - which is why setting state to NaN twice doesn't re-render, and why it treats 0 and -0 as different.

  13. 13Why is 0.1 + 0.2 not 0.3?

    Because JavaScript numbers are IEEE 754 doubles - binary fractions. Neither 0.1 nor 0.2 has an exact binary representation, so each is stored slightly off, and the errors don't cancel. You get 0.30000000000000004.

    The same thing happens in base ten with 1/3: you can't write it exactly with finite digits. It isn't a JavaScript flaw - Java, C and Python doubles all do it. What is JavaScript-specific is that there's no separate integer or decimal type by default.

    Follow-up So how do you compare floats?

    Compare against a tolerance: Math.abs(a - b) < Number.EPSILON for values near 1, or a domain-appropriate epsilon otherwise. Never === on computed floats.

  14. 14How do you handle money then?

    Store integer minor units - pence, cents - and divide only when displaying. Or use BigInt for large exact integers, or a decimal library if you need fractional precision beyond that.

    This is the practical follow-up to the 0.1 + 0.2 question and it's the one that shows production experience. "I'd store it in cents as an integer" is the whole answer.

  15. 15typeof vs instanceof?

    typeof returns a string naming a primitive type and is safe on undeclared names. instanceof walks the prototype chain to ask whether a constructor's prototype appears on it - so it only works on objects, and only within one realm.

    Follow-up When does instanceof give the wrong answer?

    Across realms - an array from an iframe or a Node vm context fails arr instanceof Array, because that realm has its own Array with its own prototype. That's exactly why Array.isArray exists.

  16. 16How do you check if something is an array?

    Array.isArray(x). typeof says "object" for arrays, and instanceof Array breaks across realms.

    Follow-up And a general type check?

    Object.prototype.toString.call(x) gives '[object Array]', '[object Date]', '[object Null]' and so on. It's the old reliable way to distinguish built-ins, though it can be spoofed via Symbol.toStringTag.

  17. 17What's the difference between value and reference semantics?

    Primitives are copied - assign one to a new variable and you have two independent values. Objects are copied by reference - both variables point at the same object, so a mutation through one is visible through the other.

    Follow-up Is JavaScript pass by reference?

    No - it's pass by value, where the value of an object variable happens to be a reference. That's why reassigning a parameter inside a function doesn't affect the caller, but mutating its properties does.

  18. 18Why is { a: 1 } === { a: 1 } false?

    Because === on objects compares identity, not contents. Those are two separately allocated objects, so the references differ. There is no built-in structural comparison in the language.

  19. 19How do you compare two objects for equality?

    Write a recursive deepEqual, or use one from a library. There's nothing built in - === is identity and JSON.stringify comparison is a shortcut with real failure modes.

    Follow-up What's wrong with the JSON.stringify trick?

    Three things. Key order matters, so {a:1,b:2} and {b:2,a:1} compare unequal. Values it can't represent are dropped or mangled - undefined, functions and symbols vanish, NaN and Infinity become null, Date becomes a string, and Map/Set become {}. And a circular reference throws outright.

  20. 20Shallow vs deep copy?

    A shallow copy duplicates the top level and copies references for everything nested, so nested objects are still shared. A deep copy recurses and duplicates everything, so the two are fully independent.

    Spread and Object.assign are both shallow - one level. That's why const b = { ...a }; b.nested.x = 1 still changes a.nested.x, which is the single most common surprise in a React reducer.

  21. 21How do you deep copy an object?

    structuredClone(obj) - built into browsers and Node now. It handles Dates, Maps, Sets, TypedArrays, RegExps and circular references.

    Follow-up What can't it clone?

    Functions, DOM nodes, symbols, class instances (you get a plain object, prototype lost), and property descriptors like getters. It throws on functions rather than dropping them. JSON.parse(JSON.stringify(x)) is the old way and drops far more.

  22. 22?? vs ||?

    || falls back on any falsy value. ?? falls back only on null or undefined. So 0 || 10 is 10 and 0 ?? 10 is 0.

    The bug it was added to fix is real and common: a quantity of 0, an empty string from a cleared input, or an explicit false from a setting all get silently replaced by the default under ||. If the fallback means "when nothing was provided", you want ??.

    Follow-up Can you mix ?? with || or &&?

    Not without parentheses - it's a SyntaxError, deliberately, because the precedence would be ambiguous to a reader.

  23. 23What is optional chaining and what does it not do?

    a?.b returns undefined instead of throwing when a is null or undefined. It does NOT protect you from a missing property two levels down - a?.b.c still throws if a.b is undefined, because only the marked link is guarded.

    It short-circuits: once it hits nullish, the whole rest of the chain is skipped and the expression is undefined. It also works for calls (fn?.()) and indexing (arr?.[0]), which is how you call a possibly-absent callback in one line.

    Follow-up Can it be overused?

    Yes. Sprinkling ?. everywhere converts a loud crash into a silent undefined that surfaces somewhere further away. Use it where the value is genuinely optional, not to quiet an error you don't understand.

  24. 24Why should you always pass a radix to parseInt?

    Because without one the radix is inferred: a leading 0x means hex, and anything else means base 10. That inference plus map(parseInt) passing the index as the radix is a classic bug. parseInt(x, 10) removes the guesswork.

    The old reason was worse - before ES5, a leading zero meant octal, so parseInt('08') was 0. That's fixed in modern engines, but the habit is still correct because the map case has nothing to do with the octal rule.

  25. 25parseInt vs Number vs the unary plus?

    parseInt reads leading digits and stops at the first character it can't use - parseInt('12px') is 12. Number and unary + convert the whole string or give NaN - Number('12px') is NaN. Number('') is 0; parseInt('') is NaN.

    So use parseInt when you genuinely want to parse a prefix out of something like a CSS value, and Number when the whole string should be a number and anything else is an error. Silently accepting '12px' as 12 in a form field is a bug, not a feature.

  26. 26What is new String('x') and why avoid it?

    A String object wrapper - an object with typeof "object" whose primitive value is 'x'. Avoid it because it's an object: === compares identity, so two of them are never equal, and it's truthy even when it wraps ''.

    new Boolean(false) is the worst version: it's an object, so it's truthy, so if (new Boolean(false)) runs the branch. Nobody writes these deliberately - they turn up in interviews precisely because they isolate the object/primitive distinction.

  27. 27If strings are primitives, why does 'abc'.length work?

    Autoboxing. When you access a property on a primitive, the engine temporarily wraps it in the corresponding object, reads the property, and discards the wrapper.

    Which is also why 'abc'.foo = 1 silently does nothing in sloppy mode - you set a property on a temporary wrapper that's immediately thrown away. In strict mode it throws instead.

  28. 28Does delete on an array element change its length?

    No. It removes the value and leaves a hole - the array stays the same length with an empty slot. Use splice to actually remove an element.

    The hole is not the same as undefined. map and forEach SKIP holes rather than visiting them with undefined, so a deleted index quietly disappears from iteration, while arr[3] still reads undefined. That inconsistency is why holes are worth avoiding entirely.

  29. 29Why does [10, 1, 3].sort() give the wrong order?

    Because the default sort converts every element to a string and compares them lexicographically. '10' sorts before '3'. Pass a comparator: .sort((a, b) => a - b).

    It's the same coercion rule as everywhere else on this page - an operation with no type-specific meaning falls back to strings. Two more details worth having: sort mutates the array in place, and since ES2019 it's required to be stable.

  30. 30What is a Symbol for?

    A guaranteed-unique property key. Two symbols with the same description are still different keys, so a symbol-keyed property can't collide with anyone else's and doesn't show up in Object.keys or JSON.stringify.

    In practice you meet them as the well-known symbols that hook into language behaviour: Symbol.iterator makes something usable in for…of and spread, Symbol.toPrimitive overrides the coercion rules discussed above.

  31. 31What is BigInt and how does it interact with numbers?

    Arbitrary-precision integers, written 10n. It exists because doubles lose integer precision above 2^53 - 1. You cannot mix it with number in arithmetic - 1n + 1 throws a TypeError.

    Follow-up Does it compare with numbers?

    Loosely, yes: 1n == 1 is true. Strictly, no: 1n === 1 is false, because the types differ. That's one of the few places == and === disagree in a way that is arguably useful.

  32. 32How do you check whether an object has a property?

    Object.hasOwn(obj, key) - or 'key' in obj if you want inherited properties to count too. obj.key !== undefined is wrong, because a property explicitly set to undefined exists.

    in walks the prototype chain, so 'toString' in {} is true. Object.hasOwn is the modern replacement for Object.prototype.hasOwnProperty.call(obj, key), which was written that way because an object can have its own property named hasOwnProperty.

  33. 33Is [] == ![] true?

    Yes, and it's the meanest one. ![] is false because [] is truthy. Then boolean-versus-object: false becomes 0, [] becomes '' via ToPrimitive, '' becomes 0, and 0 == 0.

    Nobody needs to know this. It's worth walking through once because it exercises three steps of the == algorithm in a row, and being able to narrate those three steps is the actual thing being tested.

  34. 34Is == transitive?

    No. 0 == '' is true and 0 == '0' is true, but '' == '0' is false - because the first two convert to numbers and the third is a plain string comparison.

    This is the strongest one-line argument for ===: an equality operator that isn't transitive can't be reasoned about locally. === is transitive on everything except NaN.

  35. 35How do you convert a value to a boolean explicitly?

    Boolean(x) or !!x. They're identical; !! is shorter and extremely common, Boolean() reads better in an argument list.

  36. 36What's the difference between Object.assign({}, a) and { ...a }?

    Almost nothing for plain objects - both are shallow and both copy own enumerable properties, including symbol keys. The difference is that Object.assign TRIGGERS setters on the target and reads getters as values, while spread always defines plain data properties.

    The practical consequence: spreading an object with getters flattens them into fixed values. If a getter was computing something, the copy holds the value it had at spread time and never updates.

  37. 37When would you use Map instead of a plain object?

    When keys aren't strings, when you need insertion order guaranteed for all key types, when you're adding and removing a lot, or when you need .size. A plain object coerces every key to a string, so obj[1] and obj['1'] are the same entry.

    The other one worth naming is prototype pollution: a plain object inherits toString, constructor and friends, so a user-supplied key can collide with them. Map has no such surface, and Object.create(null) is the plain-object version of the same fix.

  38. 38What does structuredClone do about circular references?

    Handles them correctly - it tracks objects it has seen and rewires the cycle in the copy. JSON.stringify throws on the same input, which is one of its three failure modes.

Predict the output

The output is hidden. Predict it before you open it.

A primitive and its wrapper, loosely compared

var x = "Raj";
var y = new String("Raj");
console.log(x == y);
Show the output
true

== with an object on one side and a primitive on the other runs ToPrimitive on the object. A String wrapper's valueOf() returns the primitive 'Raj', so the comparison becomes 'Raj' == 'Raj' and succeeds.

The same pair, strictly compared

var x = "Raj";
var y = new String("Raj");
console.log(x === y);
Show the output
false

=== does no conversion. typeof x is "string" and typeof y is "object", so the types differ and the answer is false before any value is looked at. This pair is the cleanest demonstration of what == adds.

Two wrappers, strictly compared

var x = new String("Raj");
var y = new String("Raj");
console.log(x === y);
Show the output
false

Now both sides are objects, so === compares references - and these are two separately allocated objects. Same reason {} === {} is false. Note that x == y is also false here: with objects on both sides, == compares references too, and never converts.

delete on an array

var numbers = [1, 2, 3, 4, 5];
delete numbers[3];
console.log(numbers.length);
Show the output
5

delete removes the property at index 3 and leaves a hole. length is not recomputed from the contents - it only tracks the highest index ever assigned plus one. numbers[3] now reads undefined, but map and forEach skip the slot entirely. splice(3, 1) is what actually removes an element.

map and a two-argument callback

console.log(['1', '7', '11'].map(parseInt));
Show the output
[1, NaN, 3]

map passes three arguments - value, index, array - and parseInt takes two, so the index becomes the radix. parseInt('1', 0) treats radix 0 as unspecified and gives 1. parseInt('7', 1) is invalid, since radix 1 doesn't exist, giving NaN. parseInt('11', 2) reads binary 11, which is 3.

The default sort

console.log([10, 1, 3].sort());
Show the output
[1, 10, 3]

With no comparator, sort converts every element to a string and compares lexicographically. '1' < '10' < '3' character by character. Always pass (a, b) => a - b for numbers - and remember sort mutates the original array.

The unary plus hiding in a concatenation

console.log('b' + 'a' + + 'a' + 'a');
Show the output
baNaNa

The doubled plus is one binary operator and one UNARY operator. + 'a' converts 'a' to a number, which is NaN. Then 'ba' + NaN stringifies it to 'NaN', and the final + 'a' appends. Whitespace is the only thing making this readable.

typeof, four times

console.log(typeof null);
console.log(typeof NaN);
console.log(typeof []);
console.log(typeof function () {});
Show the output
object
number
object
function

The first is the kept bug. The second is correct - NaN really is a number, the one that means "no valid numeric result". The third is why Array.isArray exists. The fourth is a deliberate convenience: functions are objects, but typeof reports them separately because it's useful.

Floating point

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log((0.1 + 0.2).toFixed(2));
Show the output
0.30000000000000004
false
0.30

Neither operand is exactly representable in binary, so each is stored slightly off and the errors compound. toFixed rounds for display and returns a STRING - it doesn't fix the comparison. For that, compare against a tolerance.

NaN and negative zero

console.log(NaN === NaN);
console.log(Object.is(NaN, NaN));
console.log(0 === -0);
console.log(Object.is(0, -0));
Show the output
false
true
true
false

These two values are precisely where Object.is and === disagree, in opposite directions. === follows IEEE 754, where NaN is unordered and the two zeros compare equal. Object.is implements SameValue, which treats each bit pattern as itself.

isNaN versus Number.isNaN

console.log(isNaN('hello'));
console.log(Number.isNaN('hello'));
console.log(isNaN(undefined));
console.log(Number.isNaN(NaN));
Show the output
true
false
true
true

The global isNaN coerces first, so it really answers "is this NOT a number after conversion" - Number('hello') is NaN, hence true. Number.isNaN returns true for exactly one value, the actual NaN, with no conversion. Use the namespaced one.

Plus, minus, and null

console.log('5' + 3);
console.log('5' - 3);
console.log(true + true);
console.log('5' + null);
console.log('5' - null);
Show the output
53
2
2
5null
5

+ concatenates whenever either side is a string, and does arithmetic otherwise - true converts to 1, so true + true is 2. - has no string meaning, so both sides go through Number(): Number('5') is 5, Number(null) is 0.

null in comparisons

console.log(null == undefined);
console.log(null === undefined);
console.log(null == 0);
console.log(null >= 0);
Show the output
true
false
false
true

The first two are the hardcoded special case. The third is the same case saying null is equal to nothing else - including 0. The fourth is genuinely inconsistent: relational operators are NOT part of that special case, so >= converts null to 0 and 0 >= 0 is true.

Non-transitive equality

console.log(0 == '');
console.log(0 == '0');
console.log('' == '0');
Show the output
true
true
false

The first two convert the string to a number - Number('') is 0, Number('0') is 0. The third has strings on both sides, so no conversion happens and they're compared character by character. An equality operator you cannot chain is the argument for === in one example.

The shallow copy

const a = { name: 'first', nested: { count: 1 } };
const b = { ...a };
b.name = 'second';
b.nested.count = 99;
console.log(a.name);
console.log(a.nested.count);
Show the output
first
99

Spread copies one level. name is a primitive, so b got its own copy and changing it left a alone. nested is a reference, so both objects point at the same inner object and the mutation is visible through either. This is the most common source of "my state changed but React didn't re-render".

Three ways JSON.stringify comparison lies

console.log(JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }));
console.log(JSON.stringify({ a: undefined }) === JSON.stringify({}));
console.log(JSON.stringify({ n: NaN, d: new Date(0), s: new Set([1]) }));
Show the output
false
true
{"n":null,"d":"1970-01-01T00:00:00.000Z","s":{}}

Key order changes the string, so equal objects compare unequal. undefined values are dropped, so different objects compare equal. And types it can't represent are silently mangled: NaN becomes null, a Date becomes a string, a Set becomes an empty object. A circular reference would throw outright - that's the fourth.

?? versus ||

console.log(0 || 'fallback');
console.log(0 ?? 'fallback');
console.log(null || 'fallback');
console.log(null ?? 'fallback');
Show the output
fallback
0
fallback
fallback

|| tests truthiness, so a legitimate 0 triggers the default. ?? tests only for null and undefined, so 0 survives. The same trap fires on '' from a cleared text input and on an explicit false in a settings object.

Optional chaining stops where you put it

const cfg = { db: null };
console.log(cfg.db?.host);
console.log(cfg.cache?.host);
console.log(cfg.cache.host);
Show the output
undefined
undefined
TypeError: Cannot read properties of undefined (reading 'host')

The first two short-circuit: ?. on a nullish left side yields undefined and skips the rest of the chain. The third has no ?., so reading .host off undefined throws as usual. ?. guards exactly the link it's written on, and nothing else.

Parsing strings to numbers

console.log(parseInt('12px'));
console.log(Number('12px'));
console.log(parseInt('0x10'));
console.log(Number(''));
console.log(Number(null));
console.log(Number(undefined));
Show the output
12
NaN
16
0
0
NaN

parseInt reads a prefix and stops at the first unusable character; Number demands the whole string. 0x is still recognised as hex when no radix is given, which is the surviving half of the old inference. The last three explain '' == 0 being true and undefined == 0 being false.

Arrays meeting operators

console.log([1, 2] + [3, 4]);
console.log([] + {});
console.log([] == false);
console.log([1] == 1);
Show the output
1,23,4
[object Object]
true
true

Every one of these is ToPrimitive running. An array's valueOf returns the array, which isn't primitive, so toString runs: [1,2] becomes '1,2' and [] becomes ''. A plain object stringifies to '[object Object]'. Then [] == false is '' == 0 is 0 == 0, and [1] == 1 is '1' == 1.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

deepEqual

The real answer to "how would you compare two objects?". Expect it right after you say the JSON trick is broken.

function deepEqual(a, b, seen = new WeakMap()) {
  // Handles primitives, and NaN vs NaN, in one line.
  if (Object.is(a, b)) return true;

  if (typeof a !== 'object' || a === null) return false;
  if (typeof b !== 'object' || b === null) return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  // Cycles: if we are already comparing this pair, assume equal.
  if (seen.get(a) === b) return true;
  seen.set(a, b);

  if (a instanceof Date) return a.getTime() === b.getTime();
  if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;

  if (Array.isArray(a)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i], seen)) return false;
    }
    return true;
  }

  if (a instanceof Map) {
    if (a.size !== b.size) return false;
    for (const [k, v] of a) {
      if (!b.has(k) || !deepEqual(v, b.get(k), seen)) return false;
    }
    return true;
  }

  if (a instanceof Set) {
    if (a.size !== b.size) return false;
    for (const v of a) {
      if (!b.has(v)) return false;
    }
    return true;
  }

  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);
  if (aKeys.length !== bKeys.length) return false;

  for (const key of aKeys) {
    if (!Object.hasOwn(b, key)) return false;
    if (!deepEqual(a[key], b[key], seen)) return false;
  }
  return true;
}

console.log(deepEqual({ a: 1, b: { c: 2 } }, { b: { c: 2 }, a: 1 })); // true
console.log(deepEqual([1, [2, 3]], [1, [2, 3]]));                    // true
console.log(deepEqual(NaN, NaN));                                    // true
console.log(deepEqual({ a: 1 }, { a: 1, b: undefined }));            // false
  • Starting with Object.is gets primitives, identity and the NaN case for free. Starting with a === b would report deepEqual(NaN, NaN) as false.
  • Comparing key COUNTS both ways is what stops {a:1} matching {a:1,b:2} - checking only one direction is the bug the interviewer is looking for.
  • The seen WeakMap is the difference between this and the JSON trick on circular input, which throws.
  • Deliberate choice to flag: Object.is makes 0 and -0 unequal here. Most callers want them equal - swap the first line for an explicit primitive check if so, and say which you chose.

deepClone

What to write when structuredClone isn't available or you need to keep prototypes or functions.

function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== 'object') return value;
  if (seen.has(value)) return seen.get(value);

  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof RegExp) return new RegExp(value.source, value.flags);

  if (Array.isArray(value)) {
    const copy = [];
    seen.set(value, copy);
    for (const item of value) copy.push(deepClone(item, seen));
    return copy;
  }

  if (value instanceof Map) {
    const copy = new Map();
    seen.set(value, copy);
    for (const [k, v] of value) copy.set(deepClone(k, seen), deepClone(v, seen));
    return copy;
  }

  if (value instanceof Set) {
    const copy = new Set();
    seen.set(value, copy);
    for (const v of value) copy.add(deepClone(v, seen));
    return copy;
  }

  const copy = Object.create(Object.getPrototypeOf(value));
  seen.set(value, copy);
  for (const key of Reflect.ownKeys(value)) {
    copy[key] = deepClone(value[key], seen);
  }
  return copy;
}

const original = { d: new Date(0), list: [1, { x: 2 }] };
const copy = deepClone(original);
copy.list[1].x = 99;
console.log(original.list[1].x); // 2
  • seen.set(value, copy) BEFORE recursing, or a circular reference is infinite recursion rather than a shared node.
  • Object.create(Object.getPrototypeOf(value)) keeps class instances as instances. structuredClone does not - it returns plain objects.
  • Reflect.ownKeys picks up symbol keys too, which Object.keys and spread-with-getters would handle differently.
  • In real code, reach for structuredClone first. Write this only when you need prototypes preserved or functions carried through.

A precise type check

One function that distinguishes null, arrays, dates and plain objects - the thing typeof cannot do.

function typeOf(value) {
  // '[object Null]' -> 'null', '[object Array]' -> 'array', etc.
  return Object.prototype.toString
    .call(value)
    .slice(8, -1)
    .toLowerCase();
}

console.log(typeOf(null));        // null
console.log(typeOf(undefined));   // undefined
console.log(typeOf([]));          // array
console.log(typeOf(new Date()));  // date
console.log(typeOf(/x/));         // regexp
console.log(typeOf(NaN));         // number
console.log(typeOf(new Map()));   // map
console.log(typeOf(() => {}));    // function
  • Works across realms, unlike instanceof - an array from an iframe still reports array.
  • Symbol.toStringTag lets an object lie about its tag, so this is a convenience, not a security boundary.
  • It reports number for NaN, correctly. If you care about that case, check it separately with Number.isNaN.
← Closures, this, and curryingThe event loop, microtasks and output order →