The Specification as an Abstract Machine
ECMA-262 is not prose about a language; it is an executable model of one.
Every observable behaviour of JavaScript is the output of algorithms defined over a small set of specification types: Records, Completion Records, Property Descriptors, Environment Records. Reading the language through those algorithms replaces folklore ("coercion is weird") with mechanism ("ToPrimitive was invoked with hint default").
In this chapter
Most descriptions of JavaScript describe what code does. ECMA-262 describes what an implementation must compute. It is written as pseudo-code over abstract data structures, and every construct in the language is defined by a sequence of steps that an engine must be observationally indistinguishable from. This is the first mental shift required to reason about JavaScript precisely: stop asking "what does + do" and start asking "which abstract operations does the evaluation of AdditiveExpression : AdditiveExpression + MultiplicativeExpression invoke, and in which order".
Two type systems: language types and specification types
The specification carefully separates two kinds of values. ECMAScript language types are the values a program can hold: Undefined, Null, Boolean, String, Symbol, Number, BigInt, and Object. Specification types exist only inside the algorithms: they are the bookkeeping the abstract machine needs and are never directly observable by user code. The important ones are:
| Specification type | Role | Where it leaks into observable behaviour |
|---|---|---|
| Record | A struct with named fields written [[Name]]. | Everywhere; the notation [[Prototype]] is a Record field on an object. |
| Completion Record | The result of every algorithm step: { [[Type]]: normal | break | continue | return | throw, [[Value]], [[Target]] }. | Control flow. try/finally override semantics are defined by which Completion Record wins. |
| Reference Record | The result of evaluating an assignment target: { [[Base]], [[ReferencedName]], [[Strict]], [[ThisValue]] }. | Why obj.method() passes obj as this but (0, obj.method)() does not. |
| Property Descriptor | { [[Value]], [[Writable]], [[Get]], [[Set]], [[Enumerable]], [[Configurable]] }, possibly partial. | Object.defineProperty, Object.getOwnPropertyDescriptor, Proxy invariants. |
| Environment Record | A scope: a mapping from identifier to binding plus an [[OuterEnv]] pointer. | Closures, TDZ, var hoisting, module live bindings. |
| Abstract Closure | A specification-level function value capturing spec variables. | How Promise resolving functions and Array.prototype.sort comparators are modelled. |
The Completion Record deserves emphasis because it makes exceptions a first-class part of evaluation rather than a bolt-on. Every step that can throw is written Let x be ? Foo(), where ? is shorthand for "if Foo() returns an abrupt completion, return it immediately". The ! prefix asserts an operation cannot fail. When you see an engine's C++ code check MAYBE_RETURN after each call, you are looking at a direct transliteration of the ? operator.
// The spec (TryStatement evaluation) says: if the finally block
// produces a *normal* completion, the completion of the try/catch
// block is used; otherwise the finally block's completion wins.
function f() {
try {
return "from try";
} finally {
console.log("finally ran, normal completion -> try result kept");
}
}
function g() {
try {
return "from try";
} finally {
return "from finally"; // abrupt (return) completion overrides
}
}
function h() {
try {
throw new Error("lost");
} finally {
return "swallowed"; // a return completion overrides a throw completion
}
}
console.log(f(), "|", g(), "|", h());Nothing about this is a quirk; it is UpdateEmpty and the Completion Record precedence rules applied mechanically.
Objects are defined by internal methods, not by properties
An object in the specification is a collection of properties plus a set of internal slots (state) and internal methods (behaviour). The essential internal methods every object must provide are [[GetPrototypeOf]], [[SetPrototypeOf]], [[IsExtensible]], [[PreventExtensions]], [[GetOwnProperty]], [[DefineOwnProperty]], [[HasProperty]], [[Get]], [[Set]], [[Delete]], and [[OwnPropertyKeys]]. Function objects add [[Call]], and constructors add [[Construct]]. Every syntactic operation on an object bottoms out in one of these: a.b is ? a.[[Get]]("b", a); "b" in a is ? a.[[HasProperty]]("b"); delete a.b is ? a.[[Delete]]("b").
Ordinary objects implement these methods with the default algorithms (OrdinaryGet, OrdinaryDefineOwnProperty, ...). Exotic objects override one or more. This single distinction explains a long list of seemingly unrelated behaviours:
- Array exotic objects override
[[DefineOwnProperty]]so that writing an index ≥lengthgrowslength, and writinglengthtruncates. Arrays are otherwise completely ordinary; there is no array-ness stored in the elements. - String exotic objects synthesise integer-indexed, non-writable, non-configurable own properties for each code unit, plus
length. - Arguments exotic objects (sloppy mode only) map indices onto the formal parameter bindings, which is why
arguments[0] = 1changes the parameter in sloppy code but not in strict code. - TypedArray exotic objects override
[[Get]]/[[Set]]/[[HasProperty]]for canonical numeric index strings so thatta["1.0"]is not the same asta[1]and out-of-range writes are silently dropped instead of creating properties. - Module namespace exotic objects forward
[[Get]]to the live binding in the module's Environment Record and reject all[[Set]]/[[DefineOwnProperty]]. - Proxy exotic objects forward each internal method to a trap on the handler, subject to invariants (chapter 10).
- Bound function exotic objects implement
[[Call]]by prepending bound arguments and[[Construct]]by ignoring the boundthis.
Abstract operations: coercion as a deterministic protocol
Type conversion in JavaScript has a reputation for being arbitrary. It is not; it is defined by a small family of abstract operations with precise dispatch rules. The one that matters most is ToPrimitive(input, preferredType). For an Object it first looks up @@toPrimitive (the well-known symbol Symbol.toPrimitive). If present, it is called with a hint string: "string", "number", or "default". If absent, OrdinaryToPrimitive tries valueOf then toString (hint number/default) or toString then valueOf (hint string), accepting the first result that is not an Object.
const probe = {
[Symbol.toPrimitive](hint) {
console.log("ToPrimitive hint:", hint);
return hint === "number" ? 42 : "forty-two";
},
};
console.log("--- unary plus (ToNumber => hint number)");
+probe;
console.log("--- binary plus (ApplyStringOrNumericBinaryOperator => hint default)");
probe + "";
console.log("--- template literal (ToString => hint string)");
`${probe}`;
console.log("--- property key (ToPropertyKey => hint string)");
({})[probe];
console.log("--- relational comparison (IsLessThan => hint number)");
probe < 1;
console.log("--- loose equality with a string (IsLooselyEqual => hint default)");
probe == "forty-two";
console.log("--- Date is the only built-in whose @@toPrimitive treats default as string");
console.log(typeof (new Date(0) + 0), typeof (new Date(0) - 0));The + operator is the canonical example. ApplyStringOrNumericBinaryOperator first applies ToPrimitive to both operands with no hint (so "default"), then checks whether either result is a String. If so, both are converted with ToString and concatenated; otherwise both go through ToNumeric and, if their types agree (both Number or both BigInt), the numeric addition is performed. Mixing Number and BigInt throws a TypeError after the conversions have happened, which is why 1n + {} produces a string ("1[object Object]") rather than a TypeError: {} became a string before the numeric check.
// IsLooselyEqual(x, y) is a small decision procedure. Reproduce it,
// logging which normative step fires at each level of recursion.
const typeOf = (v) => (v === null ? "null" : typeof v);
const isPrim = (t) => t === "string" || t === "number" || t === "bigint" || t === "symbol";
const isObj = (t) => t === "object" || t === "function";
// OrdinaryToPrimitive with hint "default": valueOf first, then toString.
function toPrimitiveDefault(o) {
if (typeof o[Symbol.toPrimitive] === "function") return o[Symbol.toPrimitive]("default");
for (const m of ["valueOf", "toString"]) {
const r = o[m]();
if (!isObj(typeOf(r))) return r;
}
throw new TypeError("Cannot convert object to primitive value");
}
function isLooselyEqual(x, y, depth = 0) {
const log = (s) => console.log(" ".repeat(depth) + s);
const tx = typeOf(x), ty = typeOf(y);
if (tx === ty) { log("step 1: same type -> IsStrictlyEqual"); return x === y; }
if (x == null && y == null) { log("step 2/3: null/undefined pair -> true"); return true; }
if (tx === "number" && ty === "string") { log("step 5: Number == String -> ToNumber(y)"); return isLooselyEqual(x, Number(y), depth + 1); }
if (tx === "string" && ty === "number") { log("step 6: String == Number -> ToNumber(x)"); return isLooselyEqual(Number(x), y, depth + 1); }
if (tx === "bigint" && ty === "string") {
log("step 7: BigInt == String -> StringToBigInt(y)");
let n; try { n = BigInt(y); } catch { return false; }
return isLooselyEqual(x, n, depth + 1);
}
if (tx === "string" && ty === "bigint") { log("step 8: swap"); return isLooselyEqual(y, x, depth + 1); }
if (tx === "boolean") { log("step 9: Boolean lhs -> ToNumber(x)"); return isLooselyEqual(Number(x), y, depth + 1); }
if (ty === "boolean") { log("step 10: Boolean rhs -> ToNumber(y)"); return isLooselyEqual(x, Number(y), depth + 1); }
if (isPrim(tx) && isObj(ty)) { log("step 11: primitive == Object -> ToPrimitive(y)"); return isLooselyEqual(x, toPrimitiveDefault(y), depth + 1); }
if (isObj(tx) && isPrim(ty)) { log("step 12: Object == primitive -> ToPrimitive(x)"); return isLooselyEqual(toPrimitiveDefault(x), y, depth + 1); }
if ((tx === "bigint" && ty === "number") || (tx === "number" && ty === "bigint")) {
log("step 13: BigInt vs Number -> compare mathematical values");
const n = typeof x === "number" ? x : y, b = typeof x === "bigint" ? x : y;
return Number.isInteger(n) && BigInt(n) === b;
}
log("step 14: no rule applies -> false");
return false;
}
console.log("[] == false :", isLooselyEqual([], false), "| native:", [] == false);
console.log("null == 0 :", isLooselyEqual(null, 0), "| native:", null == 0);
console.log("'1' == 1n :", isLooselyEqual("1", 1n), "| native:", "1" == 1n);
console.log("'0x10' == 16:", isLooselyEqual("0x10", 16), "| native:", "0x10" == 16);The case ordering matters and is normative. [] == false is true because Boolean is coerced first, then the array is coerced via ToPrimitive to the empty string, then the string to the number 0.
Four notions of equality
The specification defines four sameness relations, and built-ins are explicit about which one they use. IsStrictlyEqual (===) treats NaN as unequal to itself and +0 equal to -0. SameValue (Object.is) does the opposite on both counts; it is what Object.defineProperty uses to decide whether a redefinition of a non-writable property is a no-op or a TypeError. SameValueZero treats NaN as equal to itself and +0 equal to -0; it is used by Map, Set, Array.prototype.includes, and TypedArray.prototype.includes. IsLooselyEqual (==) coerces. Array.prototype.indexOf uses IsStrictlyEqual, so [NaN].indexOf(NaN) is -1 while [NaN].includes(NaN) is true: two methods on the same prototype, two different relations, both by design.
const pairs = [[NaN, NaN], [0, -0], ["1", 1], [null, undefined]];
const rel = {
"==": (a, b) => a == b,
"===": (a, b) => a === b,
"Object.is": (a, b) => Object.is(a, b),
"SVZ (Set)": (a, b) => new Set([a]).has(b),
};
for (const [a, b] of pairs) {
const row = Object.entries(rel).map(([k, f]) => `${k}=${f(a, b)}`).join(" ");
console.log(String(a).padEnd(9), String(b).padEnd(9), row);
}
// Consequence: a Map cannot distinguish +0 from -0 as keys, and normalises -0 to +0.
const m = new Map([[-0, "zero"]]);
console.log("Map key stored as:", Object.is([...m.keys()][0], -0) ? "-0" : "+0");Well-known symbols are the language's protocol hooks
Before ES2015 the abstract operations were closed: user code could not participate in ToPrimitive except through valueOf/toString, and could not participate in iteration, instanceof, or String.prototype.split at all. Well-known symbols opened those algorithms. Each is a property key looked up at a precise step of a precise abstract operation: @@toPrimitive in ToPrimitive, @@iterator in GetIterator, @@asyncIterator in GetIterator with kind async, @@hasInstance in InstanceofOperator, @@toStringTag in Object.prototype.toString, @@species in ArraySpeciesCreate and its Promise/TypedArray/RegExp analogues, @@isConcatSpreadable in Array.prototype.concat, @@unscopables in the Object Environment Record's HasBinding, and @@match/@@matchAll/@@replace/@@search/@@split in the corresponding String methods. Knowing where each hook is consulted lets you predict its effect exactly rather than by experiment.
// Object Environment Records consult @@unscopables in HasBinding.
// Array.prototype[Symbol.unscopables] lists methods added after ES5 so
// that legacy code using `with(arr)` and a free variable named `keys`
// or `values` did not break when those methods were added.
console.log(Object.keys(Array.prototype[Symbol.unscopables]).join(", "));
// Non-strict code only (with is a SyntaxError in strict mode / modules):
const env = { visible: 1, hidden: 2, [Symbol.unscopables]: { hidden: true } };
let hidden = "outer";
with (env) {
console.log("visible =", visible, "| hidden resolves to:", hidden);
}This snippet runs in sloppy mode inside the sandbox. In a module or class body, with is a SyntaxError.
How to read an algorithm in ECMA-262
- Find the production (grammar rule) for the syntax, e.g.
MemberExpression : MemberExpression . IdentifierName. Its Runtime Semantics: Evaluation section is the algorithm. - Each step either evaluates a sub-production, invokes an abstract operation, or manipulates specification types. Follow
?(propagate abrupt completions) and!(cannot fail) annotations. - When an object is involved, identify which internal method is being called and whether the object is ordinary or exotic. Exotic overrides are listed in section 10 of the specification.
- When a value changes type, find the abstract operation (ToPrimitive, ToNumber, ToString, ToPropertyKey, ToObject, ToLength, ToIndex, ToIntegerOrInfinity) and note the exact conversion rule; most "surprises" are ToPrimitive hint choices.
- Check for host hooks (HostEnqueuePromiseJob, HostLoadImportedModule, HostEnsureCanCompileStrings). Where the spec calls a host hook, behaviour is defined by the embedder (HTML, Node), not by ECMA-262.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Operational semantics as an executable specification | Gordon Plotkin, A Structural Approach to Operational Semantics (1981) | ECMA-262's algorithm steps over specification types are structural operational semantics written in prose; the 'abstract machine' reading of chapter 1 is Plotkin's programme applied to a language standard. |
| l-values, r-values, and references | Christopher Strachey, Fundamental Concepts in Programming Languages (lecture notes, published 2000) (1967) | The Reference Record, with its base and referenced name, is Strachey's l-value; GetValue/PutValue are his r-value and assignment. |
| Completion records and exceptions as first-class control | John Goodenough, Exception Handling: Issues and a Proposed Notation (1975) | Modelling every step's result as a completion with a type is how the specification makes exceptions part of evaluation rather than a bolt-on. |
| Coercion protocols with hints | Brendan Eich (ES1 ToPrimitive), Allen Wirfs-Brock (ES2015 @@toPrimitive), ECMA-262 1st edition (1997) and 6th edition (2015) (2015) | ToPrimitive's hint parameter dates from the first edition; exposing it to user code through a well-known symbol was the ES2015 generalisation. |