Skip to content

Types, Coercion, and the Equality Algorithm

intermediate11 min read

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.

Mental Model

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:

Typetypeof resultExample
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(){}
Common Trap

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:

  1. If x and y are the same type, use === (strict equality).
  2. If one is null and the other is undefined, return true.
  3. If one is a Number and the other is a String, convert the String to a Number.
  4. If one is a BigInt and the other is a String, convert the String to a BigInt.
  5. If one is a Boolean, convert it to a Number (true→1, false→0), then re-compare.
  6. If one is an Object and the other is a primitive (String, Number, BigInt, Symbol), call ToPrimitive on the Object, then re-compare.
  7. If one is a BigInt and the other is a Number, compare mathematically.
  8. Otherwise, return false.
The Boolean trap

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, then toString()
  • hint "string": Try toString() first, then valueOf()
  • 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:

Execution Trace
Start
[] == false
Different types: Object vs Boolean
Step 5
[] == 0
Boolean → Number: false becomes 0
Step 6
"" == 0
ToPrimitive([]): valueOf() returns array (not primitive), toString() returns ""
Step 3
0 == 0
String → Number: "" becomes 0
Result
true
Same type, same value

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
Execution Trace
Parse
{} + []
Parser sees {} as empty block statement
Evaluate
+[]
Unary + on array — triggers ToPrimitive
ToPrimitive
+""
[].toString() returns empty string
ToNumber
+0
Number("") is 0
Result
0

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.
}
This is why == is dangerous

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 +)

InputResult
undefinedNaN
null0
true1
false0
""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 this

Use 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 doWhat 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
Quiz
What does [] == ![] evaluate to?
Quiz
What does '5' + 3 - 1 evaluate to?
Quiz
Which comparison correctly checks for null OR undefined (and nothing else)?
Key Rules
  1. 1JavaScript has 7 primitives (undefined, null, boolean, number, bigint, string, symbol) and Object — that's 8 types total
  2. 2The == algorithm always converts Booleans to Numbers first — it never asks 'is this truthy?'
  3. 3ToPrimitive calls valueOf() then toString() by default — override with Symbol.toPrimitive
  4. 4The only legitimate use of == in production code is x == null to check null/undefined
  5. 5Use === everywhere else — explicit conversions beat implicit ones
  6. 6{} + [] is 0 in the console because {} is parsed as an empty block, not an object
1/12