Tags: web-dev concept

Equality and Coercion

Date: 2026-09-28


=== compares without converting. == runs a short, fixed algorithm that converts both sides towards numbers — learn the algorithm once and every “wat” example becomes predictable. Then use === anyway, except for == null.


Coercion is JavaScript converting a value to another type implicitly because an operator requires it; loose equality (==) is the comparison that coerces its operands first, and strict equality (===) is the one that doesn’t.

The == algorithm

Roughly the spec’s IsLooselyEqual, in order:

x == y
1  same type?                        → compare as ===
2  null / undefined on both sides?   → true    (they equal each other and NOTHING else)
3  number vs string                  → string to number, retry
4  a boolean on either side          → boolean to number (true→1, false→0), retry
5  object vs primitive               → object to primitive, retry
                                        (Symbol.toPrimitive, else valueOf, else toString)
6  BigInt vs number / string         → compare the mathematical values
   anything else                     → false

Worked — [] == false:

[] == false
[] == 0          step 4: false → 0
'' == 0          step 5: [] → [].toString() → ''
0  == 0          step 3: '' → Number('') → 0
true

Worked — the contradiction everyone hits:

'0' == false    // true  — '0' → 0, false → 0
if ('0') {}     // runs  — a non-empty string is truthy

== and truthiness are different conversions. == heads for numbers; if asks “is this falsy?” Nothing guarantees they agree, and here they don’t.

Truthiness

The falsy list is short and closed — everything else, including '0', 'false', [] and {}, is truthy:

false   0   -0   0n   ''   null   undefined   NaN

(Plus document.all, a legacy browser object kept falsy for web compatibility.)

Three equality algorithms, not two

NaN vs NaN0 vs -0Used by
=== (strict)falsetrue===, indexOf, switch
SameValueZerotruetrueincludes, Map / Set keys
Object.is (SameValue)truefalseObject.is, React’s state comparison

So [NaN].includes(NaN) is true while [NaN].indexOf(NaN) is -1.

Other coercions that bite

  • + with a string anywhere concatenates: '5' + 1 is '51'; '5' - 1 is 4. Form input values are always strings, so qty + 1 on an input value appends
  • Default sort() compares as strings: [10, 9, 1].sort() gives [1, 10, 9]. Always pass a comparator for numbers
  • || for defaults treats 0 and '' as missing: quantity || 1 turns a real zero into one. ?? only replaces null/undefined
  • Number() vs parseInt() — Number('12px') is NaN; parseInt('12px', 10) is 12. Neither is wrong; they answer different questions
  • Object keys are strings — obj[1] and obj['1'] are the same property; a Map keeps 1 and '1' distinct

The rule in practice

  • Default to ===. It’s never surprising
  • x == null is the one defensible == — it’s true for exactly null and undefined (step 2), which is usually the question being asked. Most lint configs allow it as an exception — Linting
  • Convert explicitly at boundaries — Number(input.value) once, where the string enters, rather than trusting operators later. TypeScript catches much of this at compile time, not at the boundary — Type Systems

vs PHP: same idea, different table. PHP 8 changed how numbers compare with non-numeric strings (0 == 'abc' became false), which JavaScript never had to fix because it always converted the string to NaN — PHP Type Juggling.