Skip to content
← Journal
9 min readAta Mohammadi

Senior Frontend Interview Prep, Part 7: Specification Quirks and Coercion

The last ten questions in the JavaScript volume: ToPrimitive traced step by step, why `[] == ![]` is true, the four equality algorithms, `NaN` and `-0`, BigInt, and the ASI rule that silently eats a return value.

This is the last instalment of the JavaScript volume, and it is the one people are tempted to dismiss as trivia. It is not trivia — it is the part of the language where two engineers can disagree confidently and both be wrong, and where the answer is always "read the abstract operation".

Every output below is a real transcript from Node 26.

Coercion

91. Trace ToPrimitive

ToPrimitive(input, hint) is the operation behind every place an object has to become a primitive: +, ==, template literals, property keys, Date arithmetic.

  1. If input has a Symbol.toPrimitive method, call it with the hint ("string", "number" or "default") and use what it returns. If it returns an object, throw.
  2. Otherwise, with hint "string": try toString(), then valueOf().
  3. Otherwise (hint "number" or "default"): try valueOf(), then toString().
  4. If neither produces a primitive: TypeError: Cannot convert object to primitive value.

The only difference between hints is the order of the two attempts. That is the whole mechanism.

const money = {
  amount: 42,
  valueOf() {
    return this.amount;
  },
  toString() {
    return `£${this.amount}`;
  },
  [Symbol.toPrimitive](hint) {
    if (hint === "string") return this.toString();
    if (hint === "number") return this.amount;
    return `£${this.amount}`; // "default" — used by == and by +
  },
};

console.log(`${money}`); // hint "string"  → "£42"
console.log(+money);     // hint "number"  → 42
console.log(money + "");  // hint "default" → "£42"

Date is the one built-in where "default" behaves like "string" rather than "number" — which is why date1 + date2 concatenates two date strings while date1 - date2 gives you milliseconds.

92. Why is [] == ![] true?

[] == ![]  →  true

Step by step through the specification:

  1. ! binds tighter than ==. An array is an object, objects are truthy, so ![] is false. → [] == false
  2. If Type(y) is Boolean, return IsLooselyEqual(x, ToNumber(y)). ToNumber(false) is 0. → [] == 0
  3. If Type(x) is Object and Type(y) is Number, return IsLooselyEqual(ToPrimitive(x), y).ToPrimitive([]) == 0
  4. Hint "default", so valueOf() first — which returns the array itself, not a primitive — then toString(), which for an empty array is "". → "" == 0
  5. If Type(x) is String and Type(y) is Number, return IsLooselyEqual(ToNumber(x), y). ToNumber("") is 0. → 0 == 0
  6. true.

Nobody should write this. The reason it is asked is to see whether you reason from the algorithm or from memorised trivia — and if you can get from step 1 to step 6 out loud, the answer to "should we use ==?" writes itself.

93. Why is 0.1 + 0.2 not 0.3?

A JavaScript number is an IEEE-754 binary64 double: 1 sign bit, 11 exponent bits, 52 mantissa bits. A binary fraction can only represent sums of powers of two. One tenth is not one, and neither is one fifth, so both become infinitely repeating in binary — the same way one third repeats in decimal. Rounding to 52 bits introduces error, and adding two rounded values compounds it.

console.log((0.1 + 0.2).toPrecision(20));
0.30000000000000004441

The fixes, in order of how often they are the right one:

  1. Do not use floats for money. Store integer minor units (pence, cents) and format at the edge. This is the answer 90% of the time and the one an interviewer wants first.
  2. Compare with a tolerance, scaled to the magnitude involved: Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b)). Bare Number.EPSILON only works for values near 1 — it is the gap between 1 and the next representable double, not a universal tolerance.
  3. Use BigInt for exact large integers, or a decimal library for exact decimal arithmetic. (Decimal is a TC39 proposal, not yet a language feature.)

94. How does NaN behave?

console.log(NaN === NaN, Object.is(NaN, NaN), [NaN].indexOf(NaN), [NaN].includes(NaN));
console.log(new Set([NaN, NaN]).size);
false true -1 true
1

Four different answers because the language has four equality algorithms:

Algorithm Used by NaN vs NaN +0 vs -0
IsLooselyEqual (==) == false equal
IsStrictlyEqual (===) ===, indexOf, switch false equal
SameValueZero includes, Set, Map keys true equal
SameValue Object.is, defineProperty true not equal

Learn the table, not the individual facts. Every "surprising" result in this area is one row of it — including why new Set([NaN, NaN]) has one element and why [NaN].indexOf(NaN) is -1 while [NaN].includes(NaN) is true.

95. +0 and -0

IEEE-754 has a sign bit, and zero has one too.

console.log(+0 === -0, Object.is(+0, -0), 1 / -0);
console.log(new Map([[-0, "a"]]).has(0));
true false -Infinity
true

So === cannot tell them apart, Object.is can, and Map keys use SameValueZero and therefore cannot either.

The classic detector, which predates Object.is and still reads more clearly than it:

function isNegativeZero(value) {
  return value === 0 && 1 / value === -Infinity;
}

Where this actually bites in real code: Math.round(-0.2) is -0, and Math.sign(-0) is -0. Format one into a string and a user sees "-0" in a report. It is a one-line bug that is genuinely hard to find if you do not know the sign bit exists.

96. BigInt versus Number

console.log(5n / 2n);
try { 10n + 5; } catch (error) { console.log(error.message); }
try { JSON.stringify({ a: 1n }); } catch (error) { console.log(error.constructor.name); }
2n
Cannot mix BigInt and other types, use explicit conversions
TypeError
  • Arbitrary precision, heap-allocated, growing as needed — where Number is a fixed 64 bits.
  • No implicit mixing with Number. That is a deliberate design choice: any automatic conversion would either lose precision silently or make + unpredictable, so the language refuses. Comparison operators are the exception — 1n < 2 works.
  • Integer division truncates toward zero. There are no fractional BigInts.
  • JSON.stringify throws. There is no BigInt in JSON, and picking a representation (string? number? lossy?) is an application decision, so the specification declines to make it. Use a replacer, or a toJSON on the values you control.

The place this shows up for real is IDs. A 64-bit integer ID from a backend does not survive JSON.parse — anything past 2^53 silently changes value. The fix is to keep it a string over the wire and convert to BigInt only if you need arithmetic.

97. ?? versus ||

|| falls through on any falsy value: false, 0, "", NaN, null, undefined. ?? falls through only on null and undefined.

const settings = { retries: 0, prefix: "", verbose: false };

console.log(settings.retries || 3); // 3   ← wrong: zero retries was the intent
console.log(settings.retries ?? 3); // 0   ← right
console.log(settings.prefix || "app"); // "app" ← wrong for a deliberate empty prefix

0, "" and false are the three legitimate values that || destroys, and all three are common configuration values. Reach for ?? by default and use || only when you actually mean "any falsy value".

Two details worth having. ?? cannot be mixed with && or || without parentheses — a ?? b || c is a SyntaxError, deliberately, because the precedence would be ambiguous to readers. And there is a logical assignment form, a ??= b, which only assigns when a is nullish and — unlike a = a ?? b — does not write at all otherwise, which matters when a is a setter or a proxied property.

98. Why is typeof null === "object"?

typeof null → "object"

A 1995 implementation detail. Values were tagged pointers, with the low bits carrying the type:

Tag Type
000 object (the rest of the word is a pointer)
1 integer
010 double
100 string
110 boolean

null was the machine's null pointer — all zero bits. Its tag was therefore 000, and typeof, which just read the tag, reported "object".

It was proposed for ES6 that typeof null should return "null". It was rejected — the risk to existing sites was real and the benefit was cosmetic. So the correct null check remains value === null, or value == null when you want "null or undefined" and are being deliberate about it.

This is a good question to be asked, because the honest answer ends with "and it will never be fixed, for compatibility" — which is a small lesson about how web standards actually work.

99. How does optional chaining short-circuit?

?. evaluates its left side; if it is null or undefined, the entire chain stops and the expression is undefined.

The part that is not obvious is that short-circuiting includes argument evaluation:

let called = false;
const target = null;

const result = target?.method(((called = true), 1));

console.log("argument evaluated?", called, "", result);
argument evaluated? false → undefined

The expensive argument is never computed and its side effect never happens. Which makes ?. cheap to use liberally — and also means you should never rely on an argument's side effect inside an optionally-chained call, because it may silently not run.

Two more facts to have ready. The short circuit propagates through the whole chain: in a?.b.c.d, if a is nullish the entire thing is undefined, not a TypeError on .c. And parentheses stop it: (a?.b).c throws, because the chain ended at the closing bracket.

100. Automatic semicolon insertion

ASI inserts a virtual semicolon when the parser hits a token it cannot use, and one of these holds:

  • The offending token is on a new line.
  • The offending token is }.
  • The parser reached the end of input.

Plus the one that actually causes bugs — the restricted productions. The grammar forbids a line terminator after return, throw, yield, break, continue, and after the operand of postfix ++/--. Put a newline there and a semicolon is inserted immediately, whatever follows:

function parsePayload() {
  return
  {
    status: "ACTIVE"
  };
}

console.log(parsePayload());
undefined

The function returns undefined, and the object literal below becomes an unreachable block containing a labelled statement — which is syntactically valid, so nothing warns you.

The other direction is the "leading punctuation" hazard. A line starting with (, [, `, +, - or / binds to the previous line:

const value = compute()
[1, 2].forEach(handle) // parsed as compute()[1, 2].forEach(handle)

Which is why the semicolon-free style has the defensive-leading-semicolon convention, and why the argument is settled in practice: run a formatter, pick one style, stop thinking about it.

Closing the JavaScript volume

Seven parts, a hundred questions, one recurring lesson: JavaScript's strangeness is almost always an algorithm you have not read yet. ToPrimitive explains coercion. SameValueZero explains NaN in a Set. Restricted productions explain the missing return value. IEEE-754 explains the money bug.

That is also the most useful thing to demonstrate in the room. An interviewer asking [] == ![] is not checking whether you memorised the answer. They are checking whether, handed something unfamiliar, you reach for the specification instead of for a guess.

The volumes that follow take the same approach to TypeScript's type system, the CSS rendering pipeline, React's reconciler, and edge delivery.


Part of the Senior Frontend Interview Preparation series.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.