Skip to content

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 truthy

The 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" → NaN

NaN ("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 === NaNfalse — 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:

  1. If the object has a Symbol.toPrimitive method (3.6.6), call it with a hint ("number", "string", or "default") — full custom control.
  2. Otherwise, for hint "number"/"default": try valueOf(), and if that returns a non-primitive, fall back to toString().
  3. For hint "string" (template literals, property keys): try toString() first, then valueOf().

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            → true

No 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 === NaNfalse, 0 === -0true.

Loose equality == is not "sloppy ===" — it's a third algorithm with knowable rules:

  1. Same types? → behave exactly like ===.
  2. null == undefinedtrueand neither loosely equals anything else. (This is a special case, not coercion.)
  3. Number vs string → ToNumber the string, retry.
  4. Boolean vs anything → ToNumber the boolean (true→1, false→0), retry.
  5. 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             → false

Practice: 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 character

length, 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.toPrimitivevalueOftoString), 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, explicitlyNumber(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 but undefined→NaN, junk→NaN), ToPrimitive (Symbol.toPrimitivevalueOftoString; string-hint flips the order).
  • + prefers strings after ToPrimitive; other arithmetic forces numbers. !!x = Boolean(x). &&/|| return operands (short-circuit); ??/?. treat only null/undefined as missing — the 0 || fallback bug-fix.
  • == has five rules: same-type → ===; null == undefined only; string→number; boolean→number; object→ToPrimitive. Derive [] == false → true; null == 0 → false. Practice: always === (± the x == null idiom); Object.is fixes NaN/-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/codePointAt see code points; Intl.Segmenter sees grapheme clusters; normalize() + localeCompare for real-world text equality/order.
  • Tagged template literals receive (parts, …values) — the mechanism behind injection-safe sql tags and styled-components.

Self-test: List the 8 falsy values. Derive [] == false step by step. Why 0 || x0 ?? 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 - 10. 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 == 0true. null == 0: rule 2 is a closed special casenull 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; comparisonnormalize("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.