How it works
ToBoolean is a pure lookup on the value's type. It never calls valueOf, toString or Symbol.toPrimitive, and it never touches an object's properties, so it cannot run user code. That is why every object is truthy, including a Proxy (no trap fires) and a wrapper such as Object(0n) or Object(""):
let calls = 0;
const spy = {
valueOf() { calls++; return 0; },
toString() { calls++; return ''; },
[Symbol.toPrimitive]() { calls++; return 0; },
};
const proxy = new Proxy({}, { get() { calls++; return 0; } });
console.log(Boolean(spy), !!proxy, Boolean(Object(0n)), Boolean(Object('')), calls); // → true true true true 0
console.log(spy == 0, calls); // → true 1
The second line shows the contrast: == goes through ToPrimitive, which does call user code.
The operators then differ in what they do with the result:
if, while, for, ?: and ! use the ToBoolean result directly.
a && b and a || b evaluate a, compute ToBoolean only to decide, and then return the original operand value, never the Boolean. That is why 0 || null is null.
a ?? b skips ToBoolean entirely and checks only for undefined or null.
In V8 (observed with node --print-bytecode on Node 24.19, not a language guarantee), an if (x) test compiles to a single JumpIfToBooleanFalse bytecode, so the conversion is a dedicated instruction rather than a call to anything you could observe.
Edge cases
- Logical assignment short-circuits the write. For
a ||= b and a &&= b the spec evaluates ToBoolean on the current value and returns early without PutValue when the left side already decides the result. So a setter is not invoked when no assignment happens. ??= follows the same shape with a nullish test.
-0 and 0n are falsy, -1n and "0" are not. The rule is per value, not "looks like zero".
== true is not the same as truthy. ToBoolean is applied by if (x), but x == true converts true to 1 first, so 2 == true is false even though 2 is truthy.
const log = [];
const target = {
_n: 0,
get n() { return this._n; },
set n(value) { log.push(value); this._n = value; },
};
target.n ||= 5; // 0 is falsy → assigns
target.n ||= 9; // 5 is truthy → setter not called
target.n &&= 7; // 5 is truthy → assigns
console.log(target.n, log); // → 7 [ 5, 7 ]
let retries = 0;
retries ??= 4; // 0 is not nullish → unchanged
console.log(retries); // → 0
Real-world scenario
Truthiness checks fail at boundaries where the data is a string or a number that can legitimately be 0:
- Environment variables and query strings are strings.
FEATURE_X=false gives the string "false", which is truthy, so if (process.env.FEATURE_X) enables the feature. Parse explicitly.
- React renders
0. React's docs warn against messageCount && <p>…</p>: when messageCount is 0, the expression evaluates to 0, and React renders it. Compare explicitly (messageCount > 0 && …) or use a ternary.
const rawFlag = process.env.FEATURE_X; // run with FEATURE_X=false
console.log(Boolean(rawFlag), rawFlag === 'true'); // → true false
const parseFlag = (value) => value === 'true' || value === '1';
console.log(parseFlag('false'), parseFlag('1'), parseFlag(undefined)); // → false true false
Trade-offs
Truthiness checks are short and idiomatic for "is there anything here" on values whose type you control, such as an optional object or a non-empty string. They hide intent when the type can include 0, "", NaN or false as valid data. Prefer ?? when only null/undefined means missing, and an explicit comparison (> 0, === '', Number.isNaN) when a specific falsy value matters. Lint rules that forbid truthiness checks on non-Boolean values (for example TypeScript-aware ones) trade brevity for catching exactly these bugs; adopting one is a team call.