Skip to content

Types and Coercion

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 copy
let a = 42;
let b = a;
b = 99;
console.log(a); // 42 — unchanged
// Objects: both variables point to the same object
const x = { count: 0 };
const y = x;
y.count = 5;
console.log(x.count); // 5 — same object

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 fixable
typeof {} // 'object'
typeof [] // 'object' ← arrays are objects
typeof 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.

== performs type coercion before comparing. === does not — it checks both value and type.

0 == false // true — false coerces to 0
0 === false // false — number vs boolean
'' == false // true — both coerce to 0
'' === false // false
null == undefined // true — special rule
null === undefined // false
1 == '1' // true — '1' coerces to 1
1 === '1' // false

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'); // prints
if ({}) console.log('empty object is truthy'); // prints
if (0) console.log('zero is truthy'); // does NOT print
// Spot the coercion surprises
console.log(0 == false); // true
console.log(0 === false); // false
console.log(null == undefined); // true
console.log(null === undefined); // false
console.log([] == false); // true — [] coerces to '' coerces to 0
console.log(Boolean([])); // true — [] is truthy!
console.log(typeof null); // object
JavaScript

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, concatenates
console.log(1 - '1'); // 0 — - always coerces to number
console.log([5] - 1); // 4 — [5] coerces to the number 5 first
console.log([] + []); // '' — both sides coerce to '', then concatenate

Number('') 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('')); // 0
console.log(Number(' ')); // 0
console.log(parseInt('')); // NaN

parseInt 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 engines
console.log(parseInt('0x10')); // 16 — auto-detected as hex, radix defaulted to 16
console.log(parseInt('10', 2)); // 2 — radix 2: '10' means binary two
console.log(parseInt('19,000')); // 19 — stops at the first non-digit character

Always 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([])); // true
console.log(Array.isArray({})); // false
console.log(typeof []); // 'object' — no help here

Optional 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 missing
console.log(config.retries ?? 3); // 0 — RIGHT: ?? only falls back on null/undefined
console.log(config.timeout ?? 5000); // 5000 — timeout is genuinely absent
console.log(config.user?.profile?.email); // undefined — no throw, even though user is missing
flowchart 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
|| falls through on any falsy value; ?? only falls through on null/undefined

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 independent

JSON.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.

JavaScript

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.

ChoiceBenefitCost
if (value) / value || fallbackShort, reads well for “is this missing”Cannot tell “missing” apart from a real 0, '', or false
value ?? fallbackOnly treats null/undefined as missingSlightly less familiar to developers coming from `
{ ...obj } spreadFast, no dependency, fine for flat objectsNested objects/arrays are shared by reference — mutating a clone can mutate the original
structuredClone(obj)True deep clone, handles Date/Map/Set correctlyCannot clone functions or DOM nodes; slightly slower than a shallow spread
  • “0 and '' mean the same thing as null/undefined in an if.” They are all falsy, but 0 and '' are real, present values — ?? (not ||, not if (!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 real parseInt footgun today is missing the radix on ambiguous or 0x-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 — use Array.isArray().

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”.

What does typeof null return in JavaScript?
Which of these values is falsy?
What is the result of null == undefined?
What does this print?
const config = { retries: 0 };
console.log(config.retries || 3);
console.log(config.retries ?? 3);
What does this print, and why is it a bug?
function renderBadge(user) {
  if (user.count) {
    return user.count + ' items';
  }
  return 'No items yet';
}
console.log(renderBadge({ count: 0 }));