JavaScript

The Abstract Equality Algorithm: What == Actually Does in JavaScript

Not a list of quirks to memorize, a walk through the specification steps that produce them.

Most explanations of JavaScript’s == operator work backwards. They start from the famous surprises, [] == false, "" == 0, null == undefined, and hand you a rule of thumb to memorize each one. That approach treats the language as a pile of folklore. It isn’t. Every one of those results falls out of a single, fully specified algorithm in ECMA-262, and once you read that algorithm in order, the surprises stop being surprising. They become predictable output of a small number of well-defined steps.

The Algorithm Behind the Symbol

The specification calls it IsLooselyEqual(x, y), and it is what == actually invokes at runtime. The current structure begins with a shortcut that most explanations skip entirely: if x and y are already the same specification type, IsLooselyEqual hands the comparison straight to IsStrictlyEqual, the same algorithm === uses. In other words, == and === are not two different comparison philosophies. They are the same comparison, with == adding a coercion layer in front for the case where the two operands don’t already match.

That single fact reframes the whole operator. == is not “loose” in how it compares values of the same type, it is identical to === there. It only becomes interesting when the types differ, and that is where the specification adds a short, ordered list of conversion rules.

The Special Case Nobody Forgets

Before any type coercion happens, the algorithm carves out one pairing by hand: if either operand is undefined or null, the comparison returns true whenever the other operand is also undefined or null, and false otherwise, no conversion involved. This is why null == undefined is true while null == 0 is false, even though both look like “nothing” to a casual reader. The specification simply decided these two values are mutually interchangeable and nothing else is.

The Coercion Machinery: ToNumber and ToPrimitive

Once the same-type shortcut and the null/undefined case are out of the way, everything else in == comes down to two conversion routines defined earlier in the specification’s type conversion section, ToNumber and ToPrimitive. Understanding these two operations is the entire trick to predicting what == will do.

ToNumber: Turning Anything Into a Number

Input typeToNumber result
undefinedNaN
null+0
Boolean1 for true, +0 for false
StringParsed as a numeric literal after trimming whitespace; empty string becomes +0; anything unparsable becomes NaN
Symbol or BigIntThrows a TypeError; ToNumber refuses these outright
ObjectFirst reduced through ToPrimitive with a “number” hint, then the result is converted again

Every quirk involving a string, a boolean, or null compared against a number runs through this table. "5" == 5 is true because the string is parsed into the number 5. false == 0 is true because the boolean becomes +0. null == 0 is false specifically because null never reaches ToNumber at all, it gets caught by the undefined/null special case first and short-circuits before any numeric conversion happens.

ToPrimitive: Turning an Object Into Something Comparable

Objects, including arrays, are never compared structurally by ==. Whenever one side of the comparison is an object and the other is a string, number, BigInt, or symbol, the algorithm calls ToPrimitive on the object first and then re-runs the comparison on the result. ToPrimitive checks for a Symbol.toPrimitive method first; if the object doesn’t define one, it falls back to an ordinary sequence, trying valueOf() then toString() for a numeric hint, or the reverse order for a string hint, and uses whichever call returns a primitive value first.

This is the mechanism behind one of the most quoted oddities in the language. An array’s valueOf() is inherited from Object.prototype and returns the array itself, which is not a primitive, so ToPrimitive falls through to toString(). An empty array’s toString() produces an empty string. That empty string then goes through ToNumber and becomes +0. Nothing about this is arbitrary or accidental, it is four ordinary conversion rules applied in sequence.

Explicit type-handling branches in each algorithm, counted from the current specification structure. IsStrictlyEqual only compares within a type; IsLooselyEqual exists almost entirely to route mismatched types toward it.

Tracing the Classic Quirks Back to Their Steps

With the coercion rules in hand, the famous list of surprises stops looking like a list of exceptions and starts looking like a table of inputs to the same function.

ExpressionPath through the algorithmResult
null == undefinedCaught by the dedicated null/undefined case before any coerciontrue
"" == 0String and Number pairing: ToNumber(“”) is +0true
false == "0"Boolean coerces to +0 first, then string “0” coerces to +0 as welltrue
[] == falseArray reduces via ToPrimitive to “”, then false reduces to +0, and “” becomes +0true
[] == ![]![] evaluates to false first, then follows the [] == false path abovetrue
NaN == NaNSame-type shortcut hands off to IsStrictlyEqual, whose Number rule explicitly excludes NaNfalse

Why BigInt Changed the Shape of the Algorithm

The abstract equality algorithm is not frozen in amber from 1997. When BigInt values were added to the language, the specification had to extend IsLooselyEqual with new branches: a BigInt compared against a String goes through StringToBigInt rather than ToNumber, since converting an arbitrary-precision integer through a floating-point number could silently lose precision. A BigInt compared against a Number is handled by a dedicated mathematical-value comparison rather than routing through either type’s normal conversion. The operator’s job, resolve a type mismatch down to a comparable pair, has stayed constant even as the set of types it has to reconcile has grown.

Milestones in the abstract equality algorithm’s specification history. Source: ECMA-262 editions, TC39.

What this means for how you write comparisons

  • Default to ===. Since it skips the coercion branches entirely, it removes the entire surface area this article just walked through.
  • Reach for == null deliberately when you want to catch both null and undefined in one check, that is the one place the special case works in your favor.
  • Never compare an object or array to a primitive with == and expect a structural check, ToPrimitive will reduce it to a string or number first, not compare its contents.
  • Treat any == involving a Boolean operand with suspicion, it silently converts to 0 or 1 before anything else happens.

What We Learned

JavaScript’s == operator is not a looser version of ===, it is the same strict comparison with a short, fully specified coercion pipeline placed in front of it. When both operands already share a type, IsLooselyEqual defers immediately to IsStrictlyEqual and behaves identically to ===. The interesting behavior only appears when types differ, and it is governed entirely by ToNumber and ToPrimitive, two ordinary conversion routines defined elsewhere in the specification. Every widely quoted quirk, from [] == false to "" == 0, is the deterministic output of those routines applied in the order the specification lays out, not an inconsistency in the language. Reading the algorithm once removes the need to memorize the quirks individually, and makes it obvious why defaulting to === in day-to-day code sidesteps the entire problem.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button