Types, Coercion, and the Equality Algorithm
The Most Misunderstood Part of JavaScript
Ask a senior developer what [] == false returns and why, step by step. Most can't do it. They'll say "coercion is weird" and move on. But coercion isn't weird — it follows a precise algorithm defined in the ECMAScript specification. Once you learn that algorithm, every "WAT" moment in JavaScript becomes predictable.
This topic is the foundation everything else builds on. If you don't understand types and coercion at the spec level, you're guessing — and guessing in production breaks things.
Think of JavaScript's == operator as a type negotiation protocol. When two values of different types meet, JavaScript doesn't just compare them — it runs a conversion pipeline with specific rules about which type yields to which. The algorithm always tries to reduce both sides to the same type, preferring numbers. It's not random. It's a flowchart you can memorize.
The 8 Types
JavaScript has exactly 8 types — 7 primitives and 1 structural type:
| Type | typeof result | Example |
|---|---|---|
| Undefined | "undefined" | undefined |
| Null | "object" (spec bug, forever) | null |
| Boolean | "boolean" | true, false |
| Number | "number" | 42, 3.14, NaN, Infinity |
| BigInt | "bigint" | 42n |
| String | "string" | "hello" |
| Symbol | "symbol" | Symbol("id") |
| Object | "object" or "function" | {}, [], function(){} |
typeof null === "object" is a bug from JavaScript's first implementation in 1995. Values were stored as a type tag + value, and null was represented as the NULL pointer (0x00). The type tag for objects was also 0. So typeof checked the tag, saw 0, and returned "object". This can never be fixed without breaking the web.
Primitives vs Objects — The Core Distinction
Primitives are immutable values. When you "change" a string, you create a new one. Objects are mutable references. Two variables can point to the same object.
// Primitives — compared by value
let a = "hello";
let b = "hello";
a === b; // true — same value
// Objects — compared by reference
let x = { name: "hello" };
let y = { name: "hello" };
x === y; // false — different objects in memory
The Abstract Equality Algorithm (==)
When you write x == y, the engine runs the Abstract Equality Comparison algorithm from ECMA-262 section 7.2.14. Here are the exact steps:
- If
xandyare the same type, use===(strict equality). - If one is
nulland the other isundefined, returntrue. - If one is a Number and the other is a String, convert the String to a Number.
- If one is a BigInt and the other is a String, convert the String to a BigInt.
- If one is a Boolean, convert it to a Number (true→1, false→0), then re-compare.
- If one is an Object and the other is a primitive (String, Number, BigInt, Symbol), call
ToPrimitiveon the Object, then re-compare. - If one is a BigInt and the other is a Number, compare mathematically.
- Otherwise, return
false.
Notice step 5: Booleans are always converted to Numbers first. This is why [] == false doesn't ask "is [] falsy?" — it converts false to 0, then compares [] == 0.
ToPrimitive — How Objects Become Primitives
When == needs to convert an object to a primitive, it calls the internal ToPrimitive operation. This operation uses a hint — "number" or "string" — to decide which method to try first:
- hint "number": Try
valueOf()first, thentoString() - hint "string": Try
toString()first, thenvalueOf() - hint "default" (used by
==and+): Same as"number"
// Array ToPrimitive:
// 1. [].valueOf() returns the array itself (not a primitive)
// 2. [].toString() returns "" (empty string)
// So ToPrimitive([]) === ""
// Object ToPrimitive:
// 1. {}.valueOf() returns the object itself (not a primitive)
// 2. {}.toString() returns "[object Object]"
// So ToPrimitive({}) === "[object Object]"
Symbol.toPrimitive — The Override
Any object can define Symbol.toPrimitive to control its own conversion:
const price = {
[Symbol.toPrimitive](hint) {
if (hint === "number") return 42;
if (hint === "string") return "$42";
return 42; // default
}
};
+price; // 42 (hint "number")
`${price}`; // "$42" (hint "string")
price + 1; // 43 (hint "default")
Tracing the Famous Examples
Why [] == false is true
Let's trace the algorithm step by step:
Why {} + [] is 0 (in the console)
This is actually a parsing ambiguity, not coercion:
// In the console, {} is parsed as an empty block, not an object literal
{} + []
// Equivalent to: {}; +[]
// The +[] is unary plus on an empty array
// +[] → +ToPrimitive([]) → +"" → 0
// Force it to be an expression:
({} + []) // "[object Object]" — string concatenation
Why "0" == false is true but "0" is truthy
"0" == false; // true
// Step 5: "0" == 0 (false → 0)
// Step 3: 0 == 0 ("0" → 0)
// true
if ("0") {
// This runs! "0" is a non-empty string, which is truthy.
}
The == comparison and the if() truthiness check use completely different algorithms. == converts via ToNumber. if() calls ToBoolean. They can give opposite answers for the same value.
ToNumber, ToString, ToBoolean — The Conversion Tables
ToBoolean (used by if, &&, ||, !)
Only 7 values are falsy. Everything else is truthy:
// The complete falsy list:
false, 0, -0, 0n, "", null, undefined, NaN
// Everything else is truthy, including:
"0" // truthy (non-empty string)
" " // truthy (space is not empty)
[] // truthy (object)
{} // truthy (object)
new Boolean(false) // truthy (object!)
ToNumber (used by ==, -, *, /, unary +)
| Input | Result |
|---|---|
undefined | NaN |
null | 0 |
true | 1 |
false | 0 |
"" | 0 |
" " (whitespace) | 0 |
"42" | 42 |
"hello" | NaN |
[] | 0 (via ToPrimitive → "" → 0) |
NaN — The value that isn't equal to itself
NaN is the only value in JavaScript where x !== x is true. This is mandated by IEEE 754 floating-point arithmetic, not a JavaScript quirk. The rationale: NaN represents "not a meaningful number," so asking "is this meaningless value equal to that meaningless value?" should be false — they could represent different failed computations.
NaN === NaN; // false
NaN == NaN; // false
Number.isNaN(NaN); // true — the correct check
isNaN("hello"); // true — coerces first, avoid thisUse Number.isNaN() (no coercion) instead of the global isNaN() (coerces first).
Production Scenario: The Comparison Bug
A real bug pattern from production code:
function processUserInput(value) {
// Bug: user types "0" in an input field
if (value == false) {
// Developer thought: "skip empty input"
// Reality: "0" == false is true
// User's valid input "0" gets silently dropped
return;
}
submitForm(value);
}
The fix:
function processUserInput(value) {
// Explicit check for what you actually mean
if (value === "" || value === null || value === undefined) {
return;
}
submitForm(value);
}
| What developers do | What they should do |
|---|---|
| Using == to check for empty values == triggers coercion that collapses different values together | Use === with explicit checks for null/undefined/empty string |
| if (x == null) to check undefined null == undefined is true by spec, and null/undefined == anything else is false | if (x == null) is actually fine — the ONE good use of == |
| Treating typeof null as a type check typeof null returns 'object' due to a 27-year-old spec bug | Use x === null for null checks |
| Using isNaN() for NaN checks isNaN('hello') returns true because it coerces to Number first | Use Number.isNaN() — no coercion |
- 1JavaScript has 7 primitives (undefined, null, boolean, number, bigint, string, symbol) and Object — that's 8 types total
- 2The == algorithm always converts Booleans to Numbers first — it never asks 'is this truthy?'
- 3ToPrimitive calls valueOf() then toString() by default — override with Symbol.toPrimitive
- 4The only legitimate use of == in production code is x == null to check null/undefined
- 5Use === everywhere else — explicit conversions beat implicit ones
- 6{} + [] is 0 in the console because {} is parsed as an empty block, not an object