`==` isn't random — it follows a precise, spec-defined algorithm. Once you know the algorithm, every 'wat' example (`[] == false`, `'' == 0`) stops being magic and starts being predictable.
== triggers coercion. === never does.
null == undefined → true (ONLY this special case, hardcoded)
null == 0 → false (null does NOT convert to a number)
NaN == NaN → false (NaN never equals itself — use Number.isNaN)
ToPrimitive(obj): try Symbol.toPrimitive → valueOf() → toString()
== conversion order (roughly):
boolean → number, then re-compare
number <-> string → string becomes number
object <-> primitive → object runs ToPrimitive, then re-compare
Rule of thumb:
Always use === / !==
Only exception: `value == null` to test "null or undefined" in one check
Never compare to NaN with ==/=== — use Number.isNaN(x)
The beginner framing: JavaScript will convert values between types automatically in many situations — string concatenation with +, comparisons with ==, conditions in if. This is called coercion, and it comes in two flavors: explicit (you call Number(x), String(x), Boolean(x) yourself) and implicit (the language does it for you, silently, as part of some other operation).
The precise mental model: implicit coercion isn't random — every operator has an exact, spec-defined algorithm for what it does with mismatched types. == (Abstract Equality Comparison) and + both lean on an internal operation called ToPrimitive, which converts an object to a primitive before any further comparison happens. Once you know this one operation, the entire family of "wat" examples becomes mechanically predictable instead of magic.
The engine-level view — ToPrimitive(object, hint):
If the object has a Symbol.toPrimitive method, call it (most objects don't define this).
Otherwise, try valueOf() — if it returns a primitive, use that.
Otherwise, try toString() — use whatever primitive that returns.
const obj = { valueOf() { return 42; }, toString() { return "obj-string"; },};console.log(obj + ""); // "42" — valueOf() wins, then coerced to string for +console.log(`${obj}`); // "obj-string" — template literals hint "string", skip to toStringconsole.log([] + "");
true — the one hardcoded special casefalse — different types, strict equality never coercesfalse — NaN is never equal to anything, including itself (use Number.isNaN)true — [1] → ToPrimitive → "1" → 1 (number) → 1 == 1true — "0" → 0 (number), false → 0 (boolean) → 0 == 0false — null ONLY == undefined and itself; it does NOT coerce to 0
The last one trips up almost everyone: null == 0 is false because null's equality rule is a special hardcoded case that short-circuits before the normal type-coercion machinery ever runs — it never gets converted to a number at all.
Implement a simplified looseEquals that reproduces a few of =='s real coercion rules, to internalize the algorithm rather than just memorizing outcomes:
function looseEquals(a, b) { // Same type → behaves like === if (typeof a === typeof b) return a === b; // The null/undefined special case if (a == null && b == null) return true; // catches both null and undefined if (a == null ||
This is a simplification of the real spec algorithm (the actual ToPrimitive is more involved), but reproducing the major branches yourself is the fastest way to stop being surprised by ==.
1. Using == out of habit and getting unpredictable results#
function isEmpty(value) { return value == ""; // "fixes" empty strings, but ALSO matches 0, false, null...}isEmpty(0); // true — probably not what you meant!
=='s coercion net is wider than most people intend. Use === and check explicitly for the cases you actually care about (value === "" || value == null).
const results = [1, NaN, 3];results.includes(NaN); // true — includes() uses SameValueZero, NOT ===results.indexOf(NaN); // -1 — indexOf DOES use ===, so it "fails" to find it
NaN !== NaN under both == and === — it's the one value in JavaScript that is never equal to itself under strict equality. Use Number.isNaN(x) or Object.is(x, NaN) to test for it reliably, and be aware different built-in methods use different equality algorithms internally.
3. Relying on implicit coercion in template literals or concatenation for objects#
console.log(`User: ${user}`); // "User: [object Object]" if user has no toString/valueOf override
Without a custom toString() or Symbol.toPrimitive, objects stringify to the unhelpful "[object Object]". If you want meaningful string output, define toString() explicitly rather than relying on the default.
Use ===/!== by default. Reserve == for the one genuinely useful case: value == null as a concise check for "null or undefined" (catches both in one comparison).
Never compare against NaN with ==/===. Always use Number.isNaN().
Define toString()/valueOf()/Symbol.toPrimitive on your own classes/objects if they'll ever be coerced (logged, concatenated, compared) — don't leave it to .
Coercion itself is essentially free — a few extra internal method calls (valueOf/toString) that modern engines inline and optimize well. This is not a performance topic; the entire concern here is correctness and readability, not speed.
If you're calling toString()/valueOf() on your own objects in a hot path (e.g., inside a tight loop doing string concatenation), make sure those methods are cheap — a toString() that does expensive work will run on every implicit coercion, which is easy to trigger accidentally.
// "" — array's default toString() joins elements with commas; empty array → ""
console.log([1, 2] + ""); // "1,2" — same mechanism
object == primitive → run ToPrimitive on the object, then re-run the comparison
==
0
);
b
==
null
)
return
false
;
// boolean → number
if (typeof a === "boolean") return looseEquals(Number(a), b);
if (typeof b === "boolean") return looseEquals(a, Number(b));
// object → primitive (simplified ToPrimitive: just try valueOf then String())
if (typeof a === "object") return looseEquals(a.valueOf() !== a ? a.valueOf() : String(a), b);
if (typeof b === "object") return looseEquals(a, b.valueOf() !== b ? b.valueOf() : String(b));
// number <-> string
return Number(a) === Number(b);
}
looseEquals([], false); // true
looseEquals("0", false); // true
looseEquals(null, 0); // false
"[object Object]"
Be explicit with conversions (Number(x), String(x), Boolean(x), or !!x) rather than relying on an operator's implicit behavior — it documents intent for the next reader.