Concept
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.toPrimitivemethod, 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 toString
console.log([] + ""); // "", array's default toString() joins elements with commas; empty array → ""
console.log([1, 2] + ""); // "1,2", same mechanismThe == algorithm, traced
== between two different types roughly follows this decision tree (simplified from the spec):
null == undefined→true(a special case, hardcoded, the ONLY thingnullloosely equals besides itself)null == anything else→false, alwaysnumber == string→ convert the string to a number, then compareboolean == anything→ convert the boolean to a number (true→1,false→0), then re-run the comparison
[] == false;
// 1. false → 0 (boolean rule)
// 2. [] → ToPrimitive → "" (empty array's toString) → "" → 0 (string-to-number)
// 3. 0 == 0 → trueTrace: '' == 0
'' == 0
// string == number → convert string to number
// Number('') === 0
// 0 == 0 → trueTry It
Predict each result, then check yourself.
console.log(null == undefined);
console.log(null === undefined);
console.log(NaN == NaN);
console.log([1] == 1);
console.log("0" == false);
console.log(null == 0);Solution
true, the one hardcoded special case
false, different types, strict equality never coerces
false, NaN is never equal to anything, including itself (use Number.isNaN)
true, [1] → ToPrimitive → "1" → 1 (number) → 1 == 1
true, "0" → 0 (number), false → 0 (boolean) → 0 == 0
false, null ONLY == undefined and itself; it does NOT coerce to 0The 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 It Yourself
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 || b == null) return false;
// boolean → number
if (typeof a === "boolean") return looseEquals(Number(a), b);
if (
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 ==.
Common Mistakes
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).
2. Assuming NaN behaves like a normal value
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 itNaN !== 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 overrideWithout 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.
Best Practices
- Use
===/!==by default. Reserve==for the one genuinely useful case:value == nullas a concise check for "null or undefined" (catches both in one comparison). - Never compare against
NaNwith==/===. Always useNumber.isNaN(). - Define
toString()/valueOf()/Symbol.toPrimitiveon your own classes/objects if they'll ever be coerced (logged, concatenated, compared), don't leave it to .
Performance Tips
- 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, atoString()that does expensive work will run on every implicit coercion, which is easy to trigger accidentally.
