Spec semantics: coercion and edge cases

JavaScript · Expert · 15 min

Experts debug 'impossible' results by knowing exactly which algorithm the spec runs.

The ECMAScript spec defines behaviour through abstract operations. A handful explain most of JavaScript's notorious edge cases.

ToPrimitive(value, hint) converts objects to primitives. It first calls value[Symbol.toPrimitive](hint) if present. Otherwise, with hint "number" or "default" it tries valueOf() then toString(); with hint "string" the reverse. Plain objects' valueOf returns the object itself, so toString wins — giving "[object Object]" and, for arrays, the elements joined by commas. Date treats "default" as "string".

The `+` operator applies ToPrimitive with hint "default" to both sides; if either result is a string, it concatenates, otherwise it does numeric addition. So [1, 2] + [3] is "1,23" and [] + {} is "[object Object]". (Typed at the start of a console line, {} + [] is 0: the {} parses as an empty block, leaving unary +[].)

IsLooselyEqual (`==`): null and undefined equal each other and nothing else; booleans are converted to numbers first; objects compared with primitives go through ToPrimitive; strings and numbers compare numerically. That's why [] == false is true ([] → "" → 0, false → 0).

Four equalities: ==, === (where NaN !== NaN and 0 === -0), SameValue (Object.is: NaN equals itself, 0 and -0 differ) and SameValueZero (like SameValue but 0 equals -0), used by includes, Map and Set. indexOf uses ===, so [NaN].indexOf(NaN) is -1 while [NaN].includes(NaN) is true.

Property order (OrdinaryOwnPropertyKeys): integer-like keys in ascending numeric order, then string keys in insertion order, then symbols in insertion order. Object.keys, JSON.stringify and for...in (for own properties) follow it.

Example

const weird = {
  valueOf() { console.log("valueOf"); return 42; },
  toString() { console.log("toString"); return "str"; },
};
console.log(weird + 1);        // valueOf, then 43  (hint "default")
console.log(`${weird}`);       // toString, then "str" (hint "string")

console.log([1, 2] + [3]);     // "1,23"
console.log([] == false);      // true: [] -> "" -> 0, false -> 0
console.log(null == 0, null >= 0); // false true (== vs relational rules)

console.log(Object.is(-0, 0), -0 === 0, 1 / -0); // false true -Infinity
console.log([NaN].indexOf(NaN), [NaN].includes(NaN)); // -1 true
console.log(new Set([0, -0]).size);                    // 1 (SameValueZero)

const o = { b: 1, 2: "two", a: 2, 1: "one", [Symbol("s")]: 3 };
console.log(Reflect.ownKeys(o)); // [ '1', '2', 'b', 'a', Symbol(s) ]

Remember

  • ToPrimitive: Symbol.toPrimitive, else valueOf/toString in hint-dependent order
  • + concatenates if either primitive is a string; == coerces via ToPrimitive and ToNumber
  • includes/Map/Set use SameValueZero; indexOf uses ===; Object.is is SameValue

Watch out for

  • Using indexOf to look for NaN — it always returns -1.

Check yourself

  1. What is [1, 2] + [3]?
  2. Which call returns true?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive

Builds on

Unlocks

Nothing builds on this yet.