Types and Coercion
Primitives vs objects
Section titled “Primitives vs objects”JavaScript has two categories of values:
Primitives — immutable, stored by value:
string,number,bigint,boolean,undefined,null,symbol
Objects — mutable, stored by reference:
- Plain objects
{}, arrays[], functions,Map,Set,Date, etc.
// Primitives: each variable holds an independent copylet a = 42;let b = a;b = 99;console.log(a); // 42 — unchanged
// Objects: both variables point to the same objectconst x = { count: 0 };const y = x;y.count = 5;console.log(x.count); // 5 — same objecttypeof
Section titled “typeof”typeof returns a string describing the type of a value. A few results are surprising:
typeof 42 // 'number'typeof 'hello' // 'string'typeof true // 'boolean'typeof undefined // 'undefined'typeof null // 'object' ← historical bug, not fixabletypeof {} // 'object'typeof [] // 'object' ← arrays are objectstypeof function(){} // 'function'typeof Symbol() // 'symbol'typeof 9007n // 'bigint'The typeof null === 'object' quirk has been in the spec since JavaScript 1.0. To check for null specifically, use value === null.
== vs ===
Section titled “== vs ===”== performs type coercion before comparing. === does not — it checks both value and type.
0 == false // true — false coerces to 00 === false // false — number vs boolean
'' == false // true — both coerce to 0'' === false // false
null == undefined // true — special rulenull === undefined // false
1 == '1' // true — '1' coerces to 11 === '1' // falseTruthy and falsy
Section titled “Truthy and falsy”Every value is either truthy or falsy when used in a boolean context.
Falsy values — only these six:
false,0,''(empty string),null,undefined,NaN
Everything else is truthy — including [], {}, '0', and 'false'.
if ([]) console.log('empty array is truthy'); // printsif ({}) console.log('empty object is truthy'); // printsif (0) console.log('zero is truthy'); // does NOT printRunnable coercion demo
Section titled “Runnable coercion demo”// Spot the coercion surprisesconsole.log(0 == false); // trueconsole.log(0 === false); // falseconsole.log(null == undefined); // trueconsole.log(null === undefined); // falseconsole.log([] == false); // true — [] coerces to '' coerces to 0console.log(Boolean([])); // true — [] is truthy!console.log(typeof null); // objectMore coercion traps
Section titled “More coercion traps”if, +, -, Number(), and parseInt() each coerce in a different, easy-to-misremember way.
if only cares about truthy/falsy — not “is this a meaningful value”. 0, NaN, and '' are all falsy and all fall into the same branch as null/undefined, even though 0 and '' can be perfectly valid data:
if (0) console.log('never — 0 is falsy');if (NaN) console.log('never — NaN is falsy');if ('') console.log('never — empty string is falsy');if ([]) console.log('runs — an empty array is truthy');if ({}) console.log('runs — an empty object is truthy');+ and - disagree on strings. + is overloaded for both numeric addition and string concatenation, so if either operand is a string, + concatenates. - has no string meaning, so it always coerces both sides to numbers first:
console.log(1 + '1'); // '11' — + sees a string, concatenatesconsole.log(1 - '1'); // 0 — - always coerces to numberconsole.log([5] - 1); // 4 — [5] coerces to the number 5 firstconsole.log([] + []); // '' — both sides coerce to '', then concatenateNumber('') is 0, not NaN. An empty or whitespace-only string converts to 0 under Number(), but parseInt('') gives NaN — the two functions disagree on empty input:
console.log(Number('')); // 0console.log(Number(' ')); // 0console.log(parseInt('')); // NaNparseInt without a radix is a real footgun — but not the one people repeat. The old “leading zero means octal” story does not apply anymore (parseInt('08') is 8 on any modern engine, per the ES5+ spec). The actual trap is 0x-prefixed strings auto-detecting hex, and any call without an explicit radix silently trusting that behavior:
console.log(parseInt('08')); // 8 — no octal trap on modern JS enginesconsole.log(parseInt('0x10')); // 16 — auto-detected as hex, radix defaulted to 16console.log(parseInt('10', 2)); // 2 — radix 2: '10' means binary twoconsole.log(parseInt('19,000')); // 19 — stops at the first non-digit characterAlways pass the radix explicitly — parseInt(str, 10) — when you mean decimal.
Array.isArray() is the only reliable array check. typeof reports 'object' for arrays (see above), and [] instanceof Array fails across different realms (iframes, worker threads, vm contexts):
console.log(Array.isArray([])); // trueconsole.log(Array.isArray({})); // falseconsole.log(typeof []); // 'object' — no help hereOptional chaining and nullish coalescing vs ||
Section titled “Optional chaining and nullish coalescing vs ||”?. short-circuits to undefined instead of throwing when a link in a property/method chain is null or undefined. ?? picks the right-hand value only when the left side is null or undefined — unlike ||, which falls through for every falsy value, including 0 and '':
const config = { retries: 0, name: '' };
console.log(config.retries || 3); // 3 — WRONG: 0 is a valid retry count, but || treats it as missingconsole.log(config.retries ?? 3); // 0 — RIGHT: ?? only falls back on null/undefined
console.log(config.timeout ?? 5000); // 5000 — timeout is genuinely absentconsole.log(config.user?.profile?.email); // undefined — no throw, even though user is missingflowchart TD
A["left ?? right"] --> B{"Is left null or undefined?"}
B -->|"yes"| C["use right"]
B -->|"no, even if 0, '', false, NaN"| D["use left"]
E["left || right"] --> F{"Is left falsy? (0, '', false, NaN, null, undefined)"}
F -->|"yes"| C
F -->|"no"| D Cloning objects: structuredClone vs spread
Section titled “Cloning objects: structuredClone vs spread”Spreading an object ({ ...obj }) or an array ([...arr]) copies only the top level — nested objects are still shared by reference. structuredClone() performs a true deep clone (and correctly recreates Date, Map, Set, typed arrays), but it cannot clone functions:
const original = { name: 'demo', tags: ['a', 'b'], meta: { count: 1 } };
const shallow = { ...original };shallow.meta.count = 99;console.log(original.meta.count); // 99 — shallow copy shared the nested "meta" object
const deep = structuredClone(original);deep.meta.count = 1;console.log(original.meta.count); // still 99 — deep clone is fully independentJSON.parse(JSON.stringify(obj)) is a common ad hoc deep-clone trick, but it silently drops undefined values and functions, and turns Date objects into plain strings — structuredClone() handles all of those correctly and should be preferred.
Runnable coercion & cloning demo
Section titled “Runnable coercion & cloning demo”Mental Model
Section titled “Mental Model”Every coercing operator answers a slightly different question: if asks “is this truthy?”, + asks “does either side look like a string?”, -/Number() ask “what number is this?”, and ?? asks “is this specifically null or undefined?”. None of them ask “did the caller actually provide a meaningful value?” — that question is exactly the one 0, '', and NaN cannot answer for you, because those are legitimate values that also happen to be falsy. Reading coercion-heavy code well means substituting 0, '', [], and {} mentally into every truthy check before trusting it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
if (value) / value || fallback | Short, reads well for “is this missing” | Cannot tell “missing” apart from a real 0, '', or false |
value ?? fallback | Only treats null/undefined as missing | Slightly less familiar to developers coming from ` |
{ ...obj } spread | Fast, no dependency, fine for flat objects | Nested objects/arrays are shared by reference — mutating a clone can mutate the original |
structuredClone(obj) | True deep clone, handles Date/Map/Set correctly | Cannot clone functions or DOM nodes; slightly slower than a shallow spread |
Common Misconceptions
Section titled “Common Misconceptions”- “
0and''mean the same thing asnull/undefinedin anif.” They are all falsy, but0and''are real, present values —??(not||, notif (!value)) is the operator that actually distinguishes “missing” from “falsy but present”. - “
parseInt('08')gives the wrong answer because of octal parsing.” That was true in pre-ES5 engines; on every engine this course targets,parseInt('08') === 8. The realparseIntfootgun today is missing the radix on ambiguous or0x-prefixed input. - “Spreading an object (
{ ...obj }) makes a fully independent copy.” It only copies one level deep — any nested object or array is still the same reference in both copies. - “
typeof arr === 'object'is fine for checking arrays.” It is true for both plain objects and arrays, so it cannot distinguish them — useArray.isArray().
Reading AI-generated code
Section titled “Reading AI-generated code”A very common shape in AI-generated code is a truthy check standing in for a “was this provided” check — if (user.count), value || 'default', or data.length && doSomething() — none of which work correctly the moment the real value is 0, '', or false. Whenever a falsy-but-valid value is possible, the check needs to be !== undefined, !== null, or ??.
🐛 Spot the bug
function renderBadge(user) {if (user.count) { return user.count + ' items';}return 'No items yet';}
console.log(renderBadge({ count: 0 })); // meant to show "0 items"Show the answer
if (user.count) treats 0 as falsy, so a user with exactly zero items falls into the “No items yet” branch — even though 0 is a perfectly valid, known count, not a missing value. The fix is to check for the absence of the value specifically: if (user.count !== undefined), or restructure with nullish coalescing so undefined/null falls back to a default while a real 0 still prints as “0 items”.