Appearance
3.6.7 — Coercion, Equality & Strings
[] + [] is "". [] + {} is "[object Object]". "5" - 1 is 4 but "5" + 1 is "51". Screenshots of these have been mocking JavaScript for decades — and the standard advice is "just memorize the weird ones." This page takes the opposite stance: there are three small conversion algorithms, and every "weird" result is those algorithms running exactly as specified. Learn the algorithms once and you can derive any result — which is also precisely what interviewers are probing when they ask. We finish with the string type itself: what a JavaScript string really is (UTF-16 code units), why "👍".length === 2, and how to handle text correctly. ⚑Unicode code units and UTF-16 in JavaScript. [EQ-205]
1. Why coercion exists at all
3.3 classified JavaScript as weakly typed: where most languages reject a type mismatch ("5" - 1 is a compile error in Java, a TypeError in Python), JavaScript converts one operand and proceeds. That was a deliberate 1995 design goal — a scripting language for web pages should keep going, not crash the page — and it froze into permanence with backward compatibility (3.6.1). Coercion is that automatic conversion. It is not random; every conversion is one of three specified algorithms:
- ToBoolean — used by
if,!,&&/||conditions; - ToNumber — used by arithmetic (
-*/%), comparisons, unary+; - ToPrimitive (then ToNumber or ToString) — used when an object meets an operator.
Master those three and the mock-worthy screenshots become boring arithmetic.
2. ToBoolean: the shortest algorithm in the language
ToBoolean doesn't inspect content — it checks membership in a fixed list. There are exactly eight falsy values; everything else is truthy:
javascript
Boolean(false); // the 8 falsy values:
Boolean(0); // false, 0, -0, 0n,
Boolean(-0); // "" (empty string),
Boolean(0n); // null, undefined, NaN
Boolean("");
Boolean(null);
Boolean(undefined);
Boolean(NaN);
Boolean([]); // → true ⚠ empty array IS truthy
Boolean({}); // → true ⚠ empty object IS truthy
Boolean("false"); // → true — a non-empty string, content irrelevant
Boolean(new Boolean(false)); // → true — it's an OBJECT (wrapper), objects are truthyThe two warnings are the real-world bugs: if (arr) does not test emptiness (arr.length > 0 does), and if (obj) does not test "has properties." Objects are always truthy — no exceptions.
!! decoded. Not an operator — the logical NOT applied twice: first ! runs ToBoolean and inverts; second inverts back. Net effect: convert to an actual boolean, identical to Boolean(x). You need it when a real true/false must be stored or returned — JSON payloads, React props, strict comparisons — rather than merely tested: const hasName = !!user.name yields false, not undefined. ⚑Why and where to use !! in JavaScript? [EQ-5]
The logical operators return operands, not booleans — they only test with ToBoolean, then hand back one of the originals, short-circuiting:
javascript
"" || "fallback"; // → "fallback" or: first TRUTHY operand (else last)
"user" && "next"; // → "next" and: first FALSY operand (else last)
0 ?? 42; // → 0 nullish coalescing: only null/undefined
0 || 42; // → 42 ⚠ || treats 0, "", false as "missing"
user?.profile?.email; // → undefined (no throw) if any link is null/undefined|| for defaults is the classic footgun when 0 or "" are legitimate values — ?? (ES2020) exists precisely to fix it, replacing only null/undefined. Its partner ?. (optional chaining) short-circuits property access the same way. Modern style: ??/?. for absence, ||/&& for genuine boolean logic and legacy defaults.
3. ToNumber and ToString: the workhorses
ToNumber, the conversions worth knowing cold:
javascript
Number("42"); // → 42 numeric string parses
Number(" 42 "); // → 42 whitespace trimmed
Number(""); // → 0 ⚠ empty string is ZERO — source of "" == 0
Number("42px"); // → NaN any junk → NaN (unlike parseInt("42px") → 42)
Number(true); // → 1 false → 0
Number(null); // → 0 ⚠ null is ZERO…
Number(undefined); // → NaN …but undefined is NaN (they differ here!)
Number([]); // → 0 via ToPrimitive: [] → "" → 0 (section 4)
Number([7]); // → 7 [7] → "7" → 7
Number([1, 2]); // → NaN [1,2] → "1,2" → NaNNaN ("Not-a-Number") deserves ten seconds of respect: it's the IEEE-754 result of a failed numeric operation (1.4), it is the only value in the language not equal to itself (NaN === NaN → false — mandated by the floating-point standard), and it's contagious through arithmetic. Test with Number.isNaN(x) (strict: is it actually the NaN value) — the older global isNaN(x) coerces first, so isNaN("hello") is true and has misled generations.
ToString on primitives is unsurprising (String(42) → "42", String(null) → "null"). The interesting half is objects — which brings in the master algorithm.
4. ToPrimitive: how objects meet operators
When an object lands in +, ==, arithmetic, or a template literal, the engine must first flatten it to a primitive. The ToPrimitive algorithm, in order:
- If the object has a Symbol.toPrimitive method (3.6.6), call it with a hint (
"number","string", or"default") — full custom control. - Otherwise, for hint
"number"/"default": tryvalueOf(), and if that returns a non-primitive, fall back totoString(). - For hint
"string"(template literals, property keys): trytoString()first, thenvalueOf().
Plain objects inherit a useless valueOf (returns the object itself) and a toString returning "[object Object]". Arrays' toString is join(","). That's every ingredient needed to derive the meme results:
javascript
[] + [] // ToPrimitive([]) → "" ; "" + "" → "" → ""
[] + {} // "" + "[object Object]" → "[object Object]"
[1] - 1 // ToPrimitive → "1" ; ToNumber("1") → 1 ; 1 - 1 → 0
[] == 0 // ToPrimitive([]) → "" ; ToNumber("") → 0 ; 0 == 0 → trueNo memorization — just the algorithm, twice. And it's genuinely useful when you control it:
javascript
class Money {
constructor(cents) { this.cents = cents; }
[Symbol.toPrimitive](hint) {
return hint === "string" ? `₹${(this.cents / 100).toFixed(2)}` : this.cents;
}
}
const price = new Money(49900);
`${price}`; // → "₹499.00" (string hint)
price > 40000; // → true (number hint)The + operator's dual personality now has a one-line spec: after ToPrimitive on both sides, if either operand is a string, concatenate; otherwise add numerically. Hence "5" + 1 → "51" (string wins) but "5" - 1 → 4 (- has no string meaning; both sides go ToNumber). Unary +x is the terse ToNumber idiom.
5. Equality: ==, ===, and Object.is
Strict equality ===: no coercion. Different types → false; same type → compare values (objects: compare references — two identical-looking literals are not equal; the same object twice is). Two IEEE-754 quirks pass through: NaN === NaN → false, 0 === -0 → true.
Loose equality == is not "sloppy ===" — it's a third algorithm with knowable rules:
- Same types? → behave exactly like
===. null == undefined→true— and neither loosely equals anything else. (This is a special case, not coercion.)- Number vs string → ToNumber the string, retry.
- Boolean vs anything → ToNumber the boolean (
true→1,false→0), retry. - Object vs primitive → ToPrimitive the object, retry.
Derive the classics, mechanically:
javascript
"5" == 5 // rule 3: 5 == 5 → true
"" == 0 // rule 3: Number("") is 0 → true
false == "0" // rule 4 then 3: 0 == 0 → true
[] == false // rule 4: [] == 0; rule 5: "" == 0;
// rule 3: 0 == 0 → true
null == 0 // rule 2: null matches ONLY undefined → false
NaN == NaN // rule 1 → === → IEEE-754 → falsePractice: always ===, with the community's one sanctioned exception — x == null as a deliberate idiom for "x is null or undefined" (rule 2 makes it exact, and it's tighter than writing both checks). Some teams ban even that; either stance is defensible, inconsistency isn't.
Object.is (ES2015) is === with the two IEEE-754 quirks corrected: Object.is(NaN, NaN) → true, Object.is(0, -0) → false. It's what Array.prototype.includes and React's state-change detection use internally. Three tools, one line each: == coerces, === doesn't, Object.is doesn't and also distinguishes the float edge cases.
6. Strings: UTF-16 code units, and why "👍".length === 2
Time to open the string type itself, because a mainstream bug class lives here. Per 1.4, Unicode assigns every character a number — a code point (now beyond 150,000 assigned). JavaScript strings predate wide Unicode: a string is a sequence of 16-bit UTF-16 code units, and every string API measured in indexes counts code units, not characters.
Sixteen bits cover code points up to U+FFFF (the Basic Multilingual Plane — Latin, Cyrillic, CJK, almost everything typed daily). Beyond that — emoji, many historic scripts, some CJK extensions — a code point needs two code units, a surrogate pair:
javascript
"A".length // → 1 one code unit, one character — the comfortable case
"👍".length // → 2 ONE character, TWO code units (a surrogate pair)
"👍".charCodeAt(0) // → 55357 (0xD83D — "high surrogate": half a character!)
"👍".codePointAt(0) // → 128077 (0x1F44D — the REAL code point)
"👍"[0] // → "\uD83D" — slicing SPLIT the characterlength, charAt, charCodeAt, slice, and index access all see code units — so they can report double counts and cut characters in half. The code-point-aware APIs (ES2015+): codePointAt, String.fromCodePoint, \u{1F44D} escapes — and, crucially, string iteration (3.6.6) walks code points, not units:
javascript
[..."hi👍"] // → ["h", "i", "👍"] spread iterates CODE POINTS
[..."hi👍"].length // → 3 the honest character count (mostly — see below)Two more floors below, so you know where the elevator stops:
Grapheme clusters. What a user calls "one character" can be several code points: 👍🏽 is thumbs-up + skin-tone modifier (2 code points, 4 code units); 👨👩👧 is three people joined by zero-width joiners ([...s].length → 5); é can be one code point or e + combining accent. The unit "what backspace should delete" is a grapheme cluster, and the correct tool is Intl.Segmenter (granularity: "grapheme") — the only honest "length in characters" JavaScript offers.
Normalization. Because é has two encodings, "é" === "é" can be false between differently-composed inputs. s.normalize("NFC") rewrites it into one standard form; do it before comparing or hashing user text. For ordering human text, === and < compare code units — use a.localeCompare(b) / Intl.Collator for language-aware sorting.
Finally, the pleasant modern surface — template literals: `Hi ${name}` interpolates (running ToPrimitive with string hint on each ${}), spans multiple lines, and has a power feature: a tagged template (tag`a ${x} b`) calls tag(stringParts, x) — letting the tag escape values, which is how sql\SELECT … ${userInput}`` libraries build injection-safe queries (Chapter 8.5) and how styled-components does CSS.
7. The expert lens
Three algorithms, zero memorization. ToBoolean (an eight-item list), ToNumber (a dozen cases), ToPrimitive (Symbol.toPrimitive → valueOf → toString), plus two operator rules (+ prefers strings; =='s five clauses). That's the complete formal system behind every JavaScript-is-broken meme — about a page of spec. The senior habit isn't avoiding the topic; it's deriving results on demand while writing code that never depends on the tricky cases: explicit Number()/String()/Boolean() at boundaries, === everywhere, ?? for absence.
Coercion is a boundary problem. Inside a well-typed core, values already have the right types and coercion never fires. It bites at the edges — URL params, form fields, env vars, JSON, headers — where everything arrives as a string. The professional pattern is the same one TypeScript reached (3.7.7): convert and validate once at the boundary, explicitly — Number(params.page), Zod schemas — then trust the interior. Implicit coercion deep inside business logic is a smell that a boundary leaked.
UTF-16 is a frozen 1995 bet — JavaScript (with Java and Windows) bet on 16 bits being enough; Unicode outgrew it; compatibility froze the API surface counting code units forever (3.6.1's permanence lesson again). Newer languages chose differently (Rust: UTF-8 strings; Python 3: code-point sequences; Swift: grapheme-first). The layered fix — code-point iteration, Intl.Segmenter — is the standard playbook for evolving a frozen API: add honest layers on top, never change the floor. Know which layer each API reads: code units (length, slice) → code points (iteration, codePointAt) → graphemes (Intl.Segmenter) → locale text (localeCompare, normalize).
Next: with values, equality, and text settled, 3.6.8 tackles time — the event loop, promises, and async/await.
Recall
- Coercion = three spec algorithms: ToBoolean (exactly 8 falsy values —
false 0 -0 0n "" null undefined NaN;[]/{}truthy), ToNumber (""→0,null→0 butundefined→NaN, junk→NaN), ToPrimitive (Symbol.toPrimitive →valueOf→toString; string-hint flips the order). +prefers strings after ToPrimitive; other arithmetic forces numbers.!!x=Boolean(x).&&/||return operands (short-circuit); ??/?.treat onlynull/undefinedas missing — the0 || fallbackbug-fix.==has five rules: same-type →===;null == undefinedonly; string→number; boolean→number; object→ToPrimitive. Derive[] == false→ true;null == 0→ false. Practice: always===(± thex == nullidiom); Object.is fixesNaN/-0.- Strings are UTF-16 code unit sequences:
length/slice/index count units, so astral characters (emoji) count 2 and can be cut in half; surrogate pairs encode them. Iteration/codePointAtsee code points;Intl.Segmentersees grapheme clusters;normalize()+localeComparefor real-world text equality/order. - Tagged template literals receive (parts, …values) — the mechanism behind injection-safe
sqltags and styled-components.
Self-test: List the 8 falsy values. Derive [] == false step by step. Why 0 || x ≠ 0 ?? x? Why does Number("") differ from Number(undefined)? Explain "👍".length === 2 in one sentence, and name the API for true user-perceived characters.
Quiz Bank
FoundationalList the falsy values and the two truthiness traps that cause real bugs.
Exactly eight: false, 0, -0, 0n, "", null, undefined, NaN — ToBoolean is pure list-membership, and everything else is truthy. The traps: empty array [] and empty object {} are truthy (objects always are), so if (arr) never tests emptiness (arr.length > 0 does) and if (obj) never tests "has properties" (Object.keys(obj).length). Bonus trap: any non-empty string is truthy including "false" and "0" — content is never inspected.
FoundationalWhat does !! do, and when do you actually need it over a plain if?
!! is double logical-NOT: the first ! runs ToBoolean and inverts; the second inverts back — net result, the value's boolean equivalent, identical to Boolean(x). A plain if (x) already coerces, so !! matters only when a real boolean must be produced, stored, or transmitted: returning true/false from a predicate, JSON payloads, React boolean props, comparisons like === true, or logging. const hasName = !!user.name gives true/false instead of leaking "Ada"/undefined. Style-wise Boolean(x) says the same thing more explicitly; !! is the entrenched idiom.
AppliedDerive [] + [], [] + {}, and [1] - 1 from the spec algorithms — no memorized answers.
All three are ToPrimitive followed by an operator rule. Arrays/objects lack Symbol.toPrimitive; their valueOf returns the object itself (useless), so toString decides: array → join(","), plain object → "[object Object]". [] + []: both → ""; + sees strings → concatenation → "". [] + {}: "" + "[object Object]" → "[object Object]". [1] - 1: - has no string meaning, so both sides go ToNumber: [1] → "1" → 1; 1 - 1 → 0. The lesson interviewers want: these aren't anomalies — they're two small algorithms (ToPrimitive, then +-prefers-strings / arithmetic-forces-numbers) composing deterministically.
InterviewHow does == actually work? Show why [] == false is true but null == 0 is false.
== runs five ordered rules: (1) same types → exactly ===; (2) null == undefined → true, and null/undefined loosely equal nothing else; (3) string vs number → ToNumber the string, retry; (4) boolean vs anything → ToNumber the boolean, retry; (5) object vs primitive → ToPrimitive the object, retry. [] == false: rule 4 → [] == 0; rule 5 → "" == 0; rule 3 → 0 == 0 → true. null == 0: rule 2 is a closed special case — null pairs only with undefined; no coercion path exists to a number → false. That asymmetry is why the one blessed use of == is the idiom x == null, an exact and terse test for "null or undefined." Everywhere else: ===. And Object.is when the IEEE-754 corners matter (NaN self-equal, 0 vs -0) — it's what React uses to decide whether state changed.
InterviewWhy is a thumbs-up emoji length 2, and how do you process strings correctly in its presence?
A JavaScript string is a sequence of 16-bit UTF-16 code units, and length, indexing, slice, and charCodeAt all count units. 👍 is code point U+1F44D — beyond the 16-bit Basic Multilingual Plane — so UTF-16 encodes it as a surrogate pair of two units (0xD83D, 0xDC4D): one character, length 2, and s[0] returns half a character (a lone surrogate — corruption if it reaches output). Correct handling by layer: code points — iterate ([...s], for…of), codePointAt, String.fromCodePoint; user-perceived characters (grapheme clusters) — skin tones/ZWJ families/combining accents span multiple code points, so use Intl.Segmenter with grapheme granularity for counting, truncation, and cursor movement; comparison — normalize("NFC") before equality (composed vs decomposed é), localeCompare/Intl.Collator for ordering. Truncating for a DB column: slice by units then verify no split (or segment first) — never blind slice(0, n) on user text.
StaffAn API handler computes price * quantity from a JSON body and query string, and production shows both NaN totals and concatenated strings like '4949'. Diagnose the class of failure and prescribe the team-level fix.
Classic boundary-coercion leak: HTTP inputs arrive as strings (query params always; JSON only if the client sends "49" instead of 49), and untyped values flowed into arithmetic. "49" * 2 happens to work (→ 98, * forces ToNumber) which is why it passed review, but "49" + 49 concatenates (→ "4949", + prefers strings) and Number("49px")/missing fields (undefined → NaN) poison totals — NaN then spreads contagiously through every downstream sum. Point diagnosis is easy; the staff-level failure is systemic: implicit coercion was doing type conversion scattered through business logic. Prescription:
(1) a validation boundary — parse and convert exactly once at ingress with a schema (Zod: z.coerce.number().int().positive() for query params; strict z.number() for JSON) so the interior only ever sees numbers, and bad input becomes a 400 at the edge instead of NaN at checkout; (2) lint/convention: ban implicit conversions in domain code — explicit Number()/String() only, === always, ?? for defaults (a 0-priced item must not trigger || defaultPrice — that's the ||-vs-?? bug hiding in money code); (3) Number.isFinite guards before persisting any computed amount, because NaN discovered at the database is NaN discovered too late; (4) tests that send the stringly versions of every numeric field. Principle:
coercion is acceptable exactly once, explicitly, at the boundary — never ambient.
Flashcards
FlashThe 8 falsy values
false, 0, -0, 0n, "", null, undefined, NaN. Everything else truthy — including [] and {}.
FlashToPrimitive order
Symbol.toPrimitive(hint) → valueOf → toString (string hint flips the last two). Arrays stringify via join(","); objects → "[object Object]".
FlashThe + rule
After ToPrimitive both sides: any string → concatenate; else numeric add. All other arithmetic forces ToNumber.
Flash== in five rules
Same type → =. nullundefined (only). String→number. Boolean→number. Object→ToPrimitive. Practice: always ===, idiom x == null allowed.
Flash|| vs ??
|| replaces any falsy (0, "" get clobbered); ?? replaces only null/undefined. Money and counts: always ??.
FlashNaN facts
Only value ≠ itself; contagious; from failed ToNumber or invalid float ops. Test: Number.isNaN (global isNaN coerces first — broken).
FlashString layers
Code units (length/slice — UTF-16, emoji = 2) → code points (for…of, codePointAt) → graphemes (Intl.Segmenter) → locale (normalize, localeCompare).
Scenario Drill
DrillA social app must enforce a 100-character bio limit consistently across a React counter, a Node validator, and a Postgres varchar — and users paste emoji, accents, and family emoji. Currently each layer disagrees and truncation sometimes produces broken characters. Design the correct scheme.
The layers disagree because each counts a different unit: input.value.length and varchar(100)-style checks count UTF-16 code units (👍 = 2, 👨👩👧 = 8-ish with ZWJs), naive spread counting sees code points (family = 5), and the user perceives grapheme clusters (family = 1). First decision, made explicitly and written down:
the product limit is 100 grapheme clusters — the only definition users can verify by looking. Implementation: a single shared counting function used verbatim by frontend and backend — count = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].length — after s.normalize("NFC") (so composed vs decomposed é can't count or compare differently between a Mac paste and an Android keyboard). Truncation must also be segment-based: keep whole segments until the budget is spent — never slice(0, 100), which can split a surrogate pair (lone surrogate = the "broken character" bug reported) or amputate a ZWJ family into separate people. Backend re-validates with the same function (client counts are UX, not enforcement — trust boundary, Chapter 8.5).
Storage: the DB column must not re-enforce a different unit: Postgres varchar(n) counts code points — safer than units but still ≠ graphemes — so size the column for the worst case in bytes/code points (100 graphemes can be several hundred code points with ZWJ sequences; e.g. text + app-level check, or a generous varchar(800)) and treat the app-layer grapheme check as the source of truth.
Tests: the pathological pack — "👍", "👍🏽", "é" both compositions, "👨👩👧👦", mixed RTL — asserted equal counts across React, Node, and a DB round-trip. Principle: pick ONE text unit (graphemes for user-facing limits), normalize first, and share the exact counting code everywhere a number is compared.