Operators, Coercion, and Equality
This chapter assumes you are comfortable with values and types in JavaScript (primitives vs. objects) and with variable declaration. You should also have written and run JavaScript before, in a browser console or Node.
JavaScript's operator semantics are where the language's reputation for surprise lives. The surprises are not random: they follow from a small set of rules — the abstract operations the spec runs before every comparison, arithmetic expression, or concatenation. Once you know those operations, [] + {} stops being a joke and becomes a prediction.
The Abstract Operations
Three internal operations do almost all the work:
- ToPrimitive(value, hint) — converts a value to a primitive before an operator uses it. It calls
[Symbol.toPrimitive](hint), thenvalueOf(), thentoString(), stopping at the first one that returns a primitive. The hint is"default","number", or"string". - ToNumber(value) — used by arithmetic,
+with a numeric hint, and relational operators. - ToString(value) — used when the string hint wins.
The critical asymmetry: ToPrimitive for the + operator uses the "default" hint, while relational operators (<, >) use the "number" hint. That difference explains several famous results.
const a = [1, 2];
const b = ['x'];
// default hint on arrays: valueOf returns the array itself (not a primitive),
// so toString is used -> "1,2"
console.log(a + b); // "1,2x"
console.log(a + 1); // "1,21" ("1,2" + "1")
// numeric hint: array -> null -> 0
console.log([] == 0); // true
console.log([5] - 1); // 4
// {} in expression position is a block, so this is unary + applied to []
console.log(+[]); // 0
console.log([] + {}); // "[object Object]"
console.log({} + []); // "[object Object]" only as a statement... Note the last caveat: pasted into a REPL as a statement, {} + [] yields 0 because {} parses as an empty block. In an expression context (console.log({} + [])) it is "[object Object]". Parser context, not coercion, produces this result.
You can control the conversion path per-type by defining Symbol.toPrimitive:
class Money {
constructor(cents) { this.cents = cents; }
[Symbol.toPrimitive](hint) {
return hint === 'number' ? this.cents / 100 : `$${(this.cents / 100).toFixed(2)}`;
}
}
const price = new Money(1999);
console.log(price + 0); // 19.99
console.log(`${price}`); // $19.99Prefer this over overriding valueOf/toString when the numeric and string views of a type genuinely differ; it removes the ordering ambiguity between the two methods.
`==` vs. `===`: The Actual Algorithm
=== (strict equality) compares values without conversion — except one historical wrinkle: NaN !== NaN, and +0 === -0 is true. (Use Object.is when you want NaN to equal itself and +0/-0 to differ; it matters for Map keys and sign-aware code.)
== (loose equality) runs a defined algorithm:
- If both operands share a type, it behaves like
===(withnull == undefinedas a special pair). null/undefinedequal each other and nothing else:null == 0isfalse, famously.- Number vs. string: the string is converted to a number.
- Boolean operand: the boolean is converted to a number first — a classic trap, since
true == '1'becomes1 == 1. - Primitive vs. object: the object is converted with
ToPrimitive(default hint). - Otherwise:
false.
Two consequences worth memorizing:
console.log('0' == false); // true (false -> 0, '0' -> 0)
console.log('' == false); // true
console.log('' == '0'); // false (same types, string comparison)
console.log(null <= 0); // true (< and == use different algorithms!)
console.log(null == 0); // falseThat last pair is the deepest gotcha: relational operators coerce null to 0, but == gives null special treatment, so x <= null and x == null disagree.
Decision rule. Use === by default. Use == deliberately and only in two idiomatic places: x == null (catches both null and undefined) and, rarely, comparing against a loosely-typed value from a legacy API. Lint tools such as ESLint's eqeqeq with the smart option encode exactly this policy.
Falsy Values and Their Edge Cases
Exactly eight values are falsy: false, 0, -0, 0n (BigInt zero), "", null, undefined, NaN. Everything else — including [], {}, new Boolean(false), and new Number(0) — is truthy, because all objects are truthy regardless of their wrapped value. That is why "is this array empty?" must be arr.length === 0, never !arr.
For empty-string checks, if (str) silently conflates undefined, null, and "". If those cases differ, test explicitly.
Operator Precedence and Associativity Traps
Coercion bugs often hide behind precedence:
console.log(1 + 2 + '3'); // "33" (left to right: 3 + '3')
console.log('1' + 2 + 3); // "123"
console.log(1 + true); // 2
console.log(NaN || 'x'); // 'x' — NaN is falsy
console.log(0 ?? 'fallback'); // 0 — nullish coalescing ignores falsiness
console.log('' ?? 'fallback'); // ''|| and && fall back on falsiness; ?? falls back only on null/undefined. Reach for ?? whenever 0 or "" are legitimate values. Also note ?? cannot be mixed with &&/|| without parentheses — the spec forbids the ambiguity.
Short-circuiting is itself the point of && and || in expressions; but remember they return an operand, not a boolean: a && b yields a when a is falsy. Code like const enabled = flag === true may be redundant; const enabled = Boolean(flag) is the explicit, coercion-safe form.
A Mental Model and a Defensive Toolkit
// Predict, then verify:
console.log(1 < 2 < 3); // true -> (1 < 2) is true -> 1 < 3
console.log(3 > 2 > 1); // false! -> (3 > 2) is true -> 1 > 1 is false
console.log([10.9] == 10); // false -> "10.9" -> 10.9
// Tools that sidestep coercion:
Number('42'); // 42; Number('') is 0, Number('x') is NaN
Number(''); // 0 — trap
parseInt('', 10); // NaN — safer for user input
Object.is(NaN, NaN); // true
Object.is(0, -0); // falseSanitize external input with Number.isFinite(Number(x)) rather than truthiness checks, and let types (TypeScript, JSDoc) shrink the surface where coercion can bite at all.
When debugging a coercion surprise, evaluate each operand's `String(x)` and `Number(x)` separately first. Most `==` mysteries resolve once you see what each side becomes before comparison.Exercise
What does [] == ![] evaluate to, and why?
Show answer
true. ![] converts the (truthy) array to false, then to the number 0. The left side [] is converted via ToPrimitive to the string "", then to the number 0. So 0 == 0.
Exercise
Why is null >= 0 true while null == 0 is false?
Show answer
Relational operators (>=) convert null to the number 0 via ToNumber, so 0 >= 0 holds. Loose equality gives null special treatment: it equals only undefined, never any other value, and no conversion is attempted.
Exercise
You need a fallback for a numeric setting that may be legitimately 0. Which operator do you use and why?
Show answer
?? (nullish coalescing). It falls back only when the value is null or undefined, preserving 0. Using || would replace 0 with the fallback because 0 is falsy.
Spotted a mistake, or code that does not work?