· 4 min read

The edge case has a derivation

Reducing JavaScript’s [] == ![] produces a deterministic chain of coercion rules and identifies where code should reject the default conversion.

Two coils of five-hole and eight-hole punched paper tape, one pale yellow and one pink, resting on a white surface.
TedColes, Public domain

[] == ![] is true, and every few months a screenshot of it makes the rounds again as proof that JavaScript is random. I have real affection for this expression, because the screenshot take gets it exactly backwards: the runtime never guesses. Six mechanical steps connect the left side to the right, and every one of them has a spec section you can point at.

reduction.jsjs
[] == ![][] == false[] == 0"" == 00 == 0true
  1. 1Precedence runs the negation before equality ever looks. ToBoolean treats every object as truthy, an empty array included, so ![] collapses to plain false.
  2. 2IsLooselyEqual now sees an object and a Boolean, and its Boolean rule converts false to positive zero.
  3. 3The object-versus-number rule asks the array for a primitive. The inherited valueOf fails politely — it returns the array itself, no primitive at all — so conversion falls through to Array.prototype.toString, and an empty array joins to the empty string.
  4. 4The string-to-number grammar maps an empty or whitespace-only string to positive zero, and 0 == 0 closes the derivation.
The full reduction: each line follows from the one above by a single spec rule.

Loose equality has earned its criticism — hiding this much machinery behind two characters is rude to everyone who reads the code later. But the derivation is where the value is, because the step that should actually worry you also names the boundary your program ought to make explicit.

Negation runs first#

An empty array, truthy? Yes — truthiness here follows object identity, and a container does not inherit the emptiness of what it holds. Because the first rewrite happens before equality, the two array literals never meet as arrays: a direct [] == [] is false, since that compares two distinct references, but here the right side has already become a Boolean before equality begins. Anyone reasoning about this as an array-to-array comparison has started one operation too late.

Equality changes the operands#

Everything below the second line is IsLooselyEqual working through its case list, one rewrite per step, until two values of one type remain. The step that should worry you is the fourth: a comparison operator just parsed a string, silently, on its way to answering a different question.

The derivation points to the repair#

Each conversion above is individually specified and individually reasonable. Composed, they make a hostile review surface, because a reader has to run a small interpreter in their head to learn which values are finally compared. Strict equality stops before the chain starts, and explicit conversion gives the policy a call site.

boundary.jsjs
[] === false // false: equality does not convert either operandconst text = input.trim();if (text === "") throw new Error("number is empty");const value = Number(text);if (!Number.isFinite(value)) throw new Error("invalid number: " + input);

Writing Number(input) and calling it a day would keep the surprising part, since the empty string still converts to zero. So the boundary carries a policy as well as a call: reject empty text before converting, reject non-finite results after, and put the offending input in the error. Code past this point uses === and never volunteers to parse anything.

The policy is a choice, and other programs choose differently — a spreadsheet that wants blank cells to mean zero can adopt the specification's conversion on purpose. The derivation pays for itself either way, because the blank-means-zero behavior becomes a decision with a name on it rather than a side effect of ==.

When the expression turns up in a console, the final line is the boring part — two numeric zeroes, equal as promised. The alarm lives four lines up, where a comparison operator volunteered to parse an empty string, and that is the line your input boundary exists to delete.