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 == undefinedis true, and neither is==to anything else - not0, not'', notfalse. This is a hardcoded special case, and it is the one genuinely useful use of==:x == nulltests "null or undefined" in one comparison.- number and string
- The string is converted to a number. So
'5' == 5is true and'' == 0is true, becauseNumber('')is0. - boolean and anything
- The BOOLEAN is converted to a number first -
truebecomes1,falsebecomes0- and then the comparison starts over. This is why'1' == trueis true and'yes' == trueis 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 forDate- 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.
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.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 needtypeof x === 'object' && x !== null, and that null guard is the point of the question.03
undefinedvsnull?undefinedis the language's absence - a variable declared but not assigned, a missing property, a parameter you didn't pass, a function with no return.nullis 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 nullis"object"whiletypeof undefinedis"undefined",Number(null)is0whileNumber(undefined)isNaN, and default parameters fire forundefinedbut NOT fornull.Follow-up Are they equal?
null == undefinedis true,null === undefinedis false. That's a special case in the==algorithm, and it's the reasonx == nullis the one loose comparison worth writing.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 typenumber, 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.
05How do you test whether something is
NaN?Number.isNaN(x). The globalisNaN(x)coerces its argument to a number first, soisNaN('hello')is true - which is almost never what you meant.Number.isNaNreturns true for exactly one value: the actual NaN. The trick everyone mentions isx !== x, which works for the same reason and is whatNumber.isNaNdoes internally. Use the named function; the trick is a comment away from being deleted by someone who thinks it's a typo.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.
nullandundefinedare 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 throughToPrimitive. NaN is equal to nothing.07Is
==ever the right choice?One case:
x == null, which is true for exactlynullandundefinedand nothing else. It's shorter and less noisy thanx === null || x === undefined, and it's a common lint exception for that reason.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 wantarr.length.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.10Why is
'5' + 3a string but'5' - 3a number?+is overloaded: if either side is a string afterToPrimitive, 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'is10and'5' + '2'is'52'.11What is
ToPrimitive?The conversion an object goes through when it meets an operator that wants a primitive. It tries
valueOf()thentoString()for most objects, andtoString()first forDate, 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, sotoString()runs and gives''.[1,2].toString()gives'1,2'. Once you know that,[] == 0and[1,2] + [3]stop being mysterious.12
Object.isvs===?Identical except for two values:
Object.is(NaN, NaN)is true where===says false, andObject.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
NaNtwice doesn't re-render, and why it treats0and-0as different.13Why is
0.1 + 0.2not0.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.EPSILONfor values near 1, or a domain-appropriate epsilon otherwise. Never===on computed floats.14How do you handle money then?
Store integer minor units - pence, cents - and divide only when displaying. Or use
BigIntfor 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
typeofvsinstanceof?typeofreturns a string naming a primitive type and is safe on undeclared names.instanceofwalks the prototype chain to ask whether a constructor'sprototypeappears on it - so it only works on objects, and only within one realm.Follow-up When does
instanceofgive the wrong answer?Across realms - an array from an iframe or a Node vm context fails
arr instanceof Array, because that realm has its ownArraywith its own prototype. That's exactly whyArray.isArrayexists.16How do you check if something is an array?
Array.isArray(x).typeofsays"object"for arrays, andinstanceof Arraybreaks 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 viaSymbol.toStringTag.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.
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.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 andJSON.stringifycomparison 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,NaNandInfinitybecomenull,Datebecomes a string, andMap/Setbecome{}. And a circular reference throws outright.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.assignare both shallow - one level. That's whyconst b = { ...a }; b.nested.x = 1still changesa.nested.x, which is the single most common surprise in a React reducer.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
??vs||?||falls back on any falsy value.??falls back only onnullorundefined. So0 || 10is10and0 ?? 10is0.The bug it was added to fix is real and common: a quantity of
0, an empty string from a cleared input, or an explicitfalsefrom 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.
23What is optional chaining and what does it not do?
a?.breturnsundefinedinstead of throwing whenaisnullorundefined. It does NOT protect you from a missing property two levels down -a?.b.cstill throws ifa.bis 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 silentundefinedthat surfaces somewhere further away. Use it where the value is genuinely optional, not to quiet an error you don't understand.24Why should you always pass a radix to
parseInt?Because without one the radix is inferred: a leading
0xmeans hex, and anything else means base 10. That inference plusmap(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')was0. That's fixed in modern engines, but the habit is still correct because themapcase has nothing to do with the octal rule.25
parseIntvsNumbervs the unary plus?parseIntreads leading digits and stops at the first character it can't use -parseInt('12px')is12.Numberand unary+convert the whole string or giveNaN-Number('12px')isNaN.Number('')is0;parseInt('')isNaN.So use
parseIntwhen you genuinely want to parse a prefix out of something like a CSS value, andNumberwhen the whole string should be a number and anything else is an error. Silently accepting'12px'as12in a form field is a bug, not a feature.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, soif (new Boolean(false))runs the branch. Nobody writes these deliberately - they turn up in interviews precisely because they isolate the object/primitive distinction.27If strings are primitives, why does
'abc'.lengthwork?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 = 1silently does nothing in sloppy mode - you set a property on a temporary wrapper that's immediately thrown away. In strict mode it throws instead.28Does
deleteon 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
spliceto actually remove an element.The hole is not the same as
undefined.mapandforEachSKIP holes rather than visiting them withundefined, so a deleted index quietly disappears from iteration, whilearr[3]still readsundefined. That inconsistency is why holes are worth avoiding entirely.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:
sortmutates the array in place, and since ES2019 it's required to be stable.30What is a
Symbolfor?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.keysorJSON.stringify.In practice you meet them as the well-known symbols that hook into language behaviour:
Symbol.iteratormakes something usable infor…ofand spread,Symbol.toPrimitiveoverrides the coercion rules discussed above.31What is
BigIntand 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 withnumberin arithmetic -1n + 1throws a TypeError.Follow-up Does it compare with numbers?
Loosely, yes:
1n == 1is true. Strictly, no:1n === 1is false, because the types differ. That's one of the few places==and===disagree in a way that is arguably useful.32How do you check whether an object has a property?
Object.hasOwn(obj, key)- or'key' in objif you want inherited properties to count too.obj.key !== undefinedis wrong, because a property explicitly set toundefinedexists.inwalks the prototype chain, so'toString' in {}is true.Object.hasOwnis the modern replacement forObject.prototype.hasOwnProperty.call(obj, key), which was written that way because an object can have its own property namedhasOwnProperty.33Is
[] == ![]true?Yes, and it's the meanest one.
![]isfalsebecause[]is truthy. Then boolean-versus-object:falsebecomes0,[]becomes''via ToPrimitive,''becomes0, and0 == 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.34Is
==transitive?No.
0 == ''is true and0 == '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 exceptNaN.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.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.assignTRIGGERS 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.
37When would you use
Mapinstead 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, soobj[1]andobj['1']are the same entry.The other one worth naming is prototype pollution: a plain object inherits
toString,constructorand friends, so a user-supplied key can collide with them.Maphas no such surface, andObject.create(null)is the plain-object version of the same fix.38What does
structuredClonedo about circular references?Handles them correctly - it tracks objects it has seen and rewires the cycle in the copy.
JSON.stringifythrows 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
falseNow 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
5delete 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
baNaNaThe 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
functionThe 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.30Neither 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
falseThese 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
trueThe 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
trueThe 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
falseThe 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
99Spread 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
NaNparseInt 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
trueEvery 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.isgets primitives, identity and the NaN case for free. Starting witha === bwould reportdeepEqual(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
seenWeakMap is the difference between this and the JSON trick on circular input, which throws. - Deliberate choice to flag:
Object.ismakes0and-0unequal 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); // 2seen.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.structuredClonedoes not - it returns plain objects.Reflect.ownKeyspicks up symbol keys too, whichObject.keysand spread-with-getters would handle differently.- In real code, reach for
structuredClonefirst. 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 reportsarray. Symbol.toStringTaglets an object lie about its tag, so this is a convenience, not a security boundary.- It reports
numberforNaN, correctly. If you care about that case, check it separately withNumber.isNaN.