How to read ECMAScript
Learn to navigate the ECMAScript specification, follow abstract operations and algorithm steps, and read internal slots, methods, and error notation.
- 01Find a ruleFollow a JavaScript expression to the relevant syntax, operation, and built-in behaviour.
- 02Read the notationTell spec-only operations from callable JavaScript, and read internal slots, methods, and ? / ! correctly.
- 03Explain an outputUse small spec steps to predict observable property keys, inherited reads, and abrupt errors.
A guide to the language rules
You already know how to use JavaScript. This lesson is about finding the written rule behind something JavaScript does. ECMAScript is the language standard that states the behaviour a conforming implementation must provide. It is not a copy of a browser engine's source code.
const cart = { tea: 3 };console.log(cart.tea);Line 1 creates a cart whose tea property holds 3. Line 2 reads that property and prints 3. The spec tells us what this read must mean, even if two engines implement the work differently.
A specification algorithm is a series of ordered rules for required behaviour. Start from the source you can run, follow only the operations it uses, and check the observable result.
The previous lesson, JavaScript & WebAssembly, explored an engine-facing boundary. Here the focus changes: a language rule says what happens, not which machine instructions a browser chooses. You do not need to understand the whole standard before reading one useful rule.
Find your way around the spec
Start with a tiny question: why does a value change type? In the example below, the text "3" becomes a number before addition. Search for the built-in you used, then follow the linked conversion rule.
const price = "3";console.log(Number(price) + 2);Line 1 stores text, not a number. Line 2 calls Number, then adds 2, so it prints 5. We can look for abstract operations, the spec's named reusable rules, when the built-in delegates to conversion.
| Part | Question | Example |
|---|---|---|
| Syntax | Which source forms are allowed? | A declaration such as const price = 3. |
| Static semantics | What can be decided from the source form? | StringValue finds an identifier's name. |
| Runtime semantics | What happens when the code runs? | Evaluation resolves a name or reads a property. |
| Abstract operations | Which reusable steps do other rules call? | ToPropertyKey converts a computed property name. |
The spec contains clauses for language types, reusable operations, objects, expressions, statements, and built-ins. A grammar production is a written shape that source code can match. A rule headed Static Semantics can inspect that shape; Runtime Semantics describes work when it runs. Search the live ECMAScript multipage specification by heading or named operation, then follow its links. Clause numbers can move between editions.
For cart.tea, begin at Property Accessors. For a computed name such as cart[key], follow GetValue and ToPropertyKey. Stop when the few relevant steps explain your output; you do not need every clause on the page.
Read one step at a time
A spec algorithm reads like a short recipe. Let gives a name to an intermediate value; If chooses a path; Return finishes that path. Assert: records a condition the surrounding rules already guarantee. An assertion is not a message printed by your page.
const price = 3;console.log(String(price));console.log(String(undefined));Line 1 stores the number 3. Line 2 prints the text 3. Line 3 prints the text undefined. Console display looks the same for some numbers and strings, so use typeof in your editor if you need to tell them apart.
The following is a short quotation of selected steps from ToString, not a program to paste into JavaScript. The first step returns a String unchanged; another names the output for undefined.
ToString ( arg )
1. If arg is a String, return arg.
2. If arg is a Symbol, throw a TypeError exception.
3. If arg is undefined, return "undefined".The word arg names the input in the algorithm, not a variable from our program. The excerpt is deliberately partial: the complete operation covers numbers, objects, and other values. The callable JavaScript String(...) is not a public alias for ToString; for example, their handling of a Symbol is not identical.
Abstract operations are reusable steps
An abstract operation is a spec-defined procedure with a name and inputs. It is not an ordinary JavaScript function. For example, ToPropertyKey prepares a value to name an object property. You cannot call it as a global from a page.
const cart = { 3: "tea" };console.log(cart[3]);console.log(cart["3"]);Line 1 makes an ordinary object with tea under key 3. Line 2 prints tea. Line 3 also prints tea: the numeric form names the same String property as "3".
Read the exact steps below from ToPropertyKey. First it obtains a primitive with a string hint. A Symbol is already a property key; another primitive becomes a String. We will unpack the punctuation in the next sections.
ToPropertyKey ( arg )
1. Let key be ? ToPrimitive(arg, STRING).
2. If key is a Symbol, return key.
3. Return ! ToString(key).Another operation, Call, checks whether a value is callable before using its [[Call]] internal method. That is a rule about calls, not a method called Call that you add to a function.
function total(price) { return price + 2;}console.log(total(3));Line 1 names a function and its input. Line 2 returns price + 2. Line 4 calls it with 3 and prints 5. The spec excerpt shows the callable check and the handoff to [[Call]], not the function's addition.
Call ( func, thisValue [ , argList ] )
1. If argList is not present, set argList to a new empty List.
2. If IsCallable(func) is false, throw a TypeError exception.
3. Return ? func.[[Call]](thisValue, argList).Follow ToPropertyKey through a real value
A number is easy to convert; an object can choose how it becomes a primitive. The following computed key has a conversion hook. Symbol.toPrimitive is a JavaScript hook the spec consults while converting this object.
const cart = {};const key = { [Symbol.toPrimitive](hint) { console.log(hint); return 3; },};cart[key] = "tea";console.log(cart["3"]);Line 1 creates an empty cart. Lines 2 through 7 create an object whose hook receives a hint. Line 8 uses it as a key, printing string from line 4 before storing tea. Line 9 reads the String key "3" and prints tea. That is the two-line output.
Use the ToPropertyKey excerpt above to read the sequence: ToPrimitive asks for a string hint; the returned 3 is not a Symbol, so ToString makes the key "3". A Symbol takes the other path instead.
const key = Symbol("tea");const cart = { [key]: 3 };console.log(cart[key]);console.log(cart["Symbol(tea)"]);Line 1 creates a Symbol. Line 2 stores 3 under that Symbol. Line 3 prints 3; line 4 prints undefined because the String "Symbol(tea)" is a different key. A Symbol's description is not the key's String replacement.
Replay instrumented example code. This is not an engine debugger or a dump of internal slots.
script
const key = { [Symbol.toPrimitive](hint) { console.log(hint); return 3;} };cart[key] = "tea";console.log(cart["3"]);This replay runs the lesson's instrumented example and shows each saved step. It is not an engine debugger. Watch for the hint, the conversion result, and the final read. Back restores earlier snapshots without changing the example.
You look up a person by the name saved in your phone. A nickname only finds them when it leads to the saved name.
- In real life: A name locates one contact
- In JavaScript: A property key locates one value
- In real life: The spelling matters
- In JavaScript: The converted key matters
- In real life: A nickname needs a lookup rule
- In JavaScript: An object key needs conversion
Where the analogy stops: JavaScript also accepts unique Symbol keys; a contact list does not model that separate key type.
Now change exactly one input. The playground uses a String key, stores tea, and reads it back. It illustrates the key-to-value connection; it does not simulate the full ToPrimitive operation.
const key = "3";const cart = {};cart[key] = "tea";console.log(cart[key]);"3"teaThe teaching model stores tea under "3", then reads tea from that same key.
Internal slots describe hidden state
An internal slot is state the specification associates with an object. The notation uses double brackets. For ordinary objects, [[Prototype]] describes the link used for inherited property reads. It is not a property you can access by typing the brackets.
const shared = { tea: 3 };const cart = Object.create(shared);console.log(cart.tea);Line 1 gives shared a tea value of 3. Line 2 creates cart with that object as its prototype. Line 3 prints 3 even though cart has no own tea property. The prototype link explains the result.
The selected step below comes from OrdinaryGetPrototypeOf. It describes the internal state read by the operation. An implementation may store that link differently, as long as its behaviour matches.
OrdinaryGetPrototypeOf ( obj )
1. Return obj.[[Prototype]].const shared = { tea: 3 };const cart = Object.create(shared);cart["[[Prototype]]"] = "label";console.log(Object.getPrototypeOf(cart) === shared);console.log(cart["[[Prototype]]"]);Line 1 creates the shared object; line 2 links the cart. Line 3 sets a normal String property named "[[Prototype]]". Line 4 prints true because the real prototype is still shared. Line 5 prints label, the separate property you just made.
The public Object.getPrototypeOf can observe the prototype link. A function's ordinary .prototype property is yet another thing: it is not the [[Prototype]] slot of the function itself. Distinguishing those names prevents a very common reading mistake.
Internal methods describe what objects do
An internal method specifies an object behaviour, such as reading a property through [[Get]]. Ordinary objects use the OrdinaryGet algorithm. Other kinds of objects, including Proxy objects, can specify different internal behaviour while meeting the spec's rules.
const shared = { tea: 3 };const cart = Object.create(shared);console.log(cart.tea);cart.tea = 5;console.log(cart.tea);Line 1 stores 3 on shared. Line 2 gives cart that prototype. Line 3 prints 3 through inheritance. Line 4 puts an own tea value of 5 on cart. Line 5 prints 5. The two logs are 3 and 5.
These selected steps from OrdinaryGet explain the first read. It asks for the object's own property. If absent, it asks its prototype and continues there. If an own data property exists, it returns its value. This excerpt stops before the accessor case; the full algorithm also handles getters.
OrdinaryGet ( obj, propertyKey, receiver )
1. Let propertyDesc be ? obj.[[GetOwnProperty]](propertyKey).
2. If propertyDesc is undefined, then
a. Let parent be ? obj.[[GetPrototypeOf]]().
b. If parent is null, return undefined.
c. Return ? parent.[[Get]](propertyKey, receiver).
3. If IsDataDescriptor(propertyDesc) is true, return propertyDesc.[[Value]].Replay the instrumented property example, not the engine's internal [[Get]] implementation.
script
const cart = Object.create(shared);console.log(cart.tea);cart.tea = 5;console.log(cart.tea);The replay calls lesson functions for inherited and own lookup and records the actual results. Its frames illustrate the visible JavaScript example, not the hidden calls an engine chose to make. Step through the first read before the assignment.
const shared = { get label() { return this.name; },};const user = Object.create(shared);user.name = "Asha";console.log(user.label);Lines 1 through 3 define a getter on shared. Line 4 creates user with that prototype. Line 5 sets user.name to Asha. Line 6 prints Asha because the inherited getter receives user as this, the receiver passed through the lookup.
The ? and ! shorthands
A completion is the spec's way to describe how an operation finishes: normally or abruptly, such as by throwing. A ? before a spec operation passes an abrupt completion back to its caller and otherwise takes the normal value. The next lesson will examine the full Completion Record shape.
const key = { toString: () => "tea" };const cart = { tea: 3 };console.log(cart[key]);Line 1 makes a key object whose toString returns tea. Line 2 stores 3 in the cart. Line 3 reads with the object key and prints 3. This is a normal completion of the key conversion.
In ToIntegerOrInfinity, the selected line Let number be ? ToNumber(arg) says not to continue to the next step if conversion throws. ReturnIfAbrupt is the name of that propagation rule, shortened by ? in spec algorithms.
ToIntegerOrInfinity ( arg )
1. Let number be ? ToNumber(arg).
2. If number is one of NaN, +0, or -0, return 0.const price = { [Symbol.toPrimitive]() { throw new Error("bad price"); },};try { console.log(Number(price)); console.log("after");} catch (error) { console.log(error.message);}Lines 1 through 3 create a value whose conversion throws bad price. Line 5 tries to convert it. Line 6 never prints after; line 8, inside the catch, prints bad price. This illustrates the same early-exit idea, without pretending we can inspect a Completion Record from JavaScript.
A ! before an operation says that this particular call is known to finish normally; it unwraps the result. In ToPropertyKey, after a primitive is not a Symbol, ! ToString(key) cannot take the Symbol error path. It does not mean logical negation, and it must not be used to ignore an error.
const cart = { 3: "tea" };console.log(cart[3]);Line 1 creates tea under a numeric-looking key. Line 2 reads with 3 and prints tea. This input takes the normal conversion path. The spec's ! is a statement about its algorithm at that point, not a character you put in front of cart[3].
Syntax-directed operations follow source shapes
A syntax-directed operation applies to a piece of parsed source, called a parse node. The spec gives steps for the grammar shape that node matches. For an identifier, StringValue gives its written name; runtime evaluation is a different question: what value does that name refer to now?
const price = 3;console.log(price);Line 1 declares a binding written price and gives it 3. Line 2 reads the binding and prints 3. The name price came from source text; 3 is the runtime value.
The excerpt below comes from Identifiers: Static Semantics: StringValue. The line beginning Identifier : tells you which grammar form the rule applies to. The indented step gives the StringValue of the IdentifierName. It is spec notation, not a runnable declaration.
Static Semantics: StringValue
Identifier : IdentifierName but not ReservedWord
1. Return the StringValue of IdentifierName.const price = 3;let total = price + 2;console.log(total);Line 1 gives price the value 3. Line 2 adds 2 and stores 5 in total. Line 3 prints 5. Both identifiers have source names; the arithmetic result is not a source name.
A register has a written name and a mark beside it. Reading the name is not the same as reading the mark.
- In real life: A student's name is written
- In JavaScript: An identifier has a source spelling
- In real life: A mark is beside the name
- In JavaScript: A binding has a current value
- In real life: The teacher reads the mark
- In JavaScript: Evaluation reads the value
Where the analogy stops: A JavaScript binding can change during execution; a register is only a comparison for name versus value.
The spec also has Runtime Semantics: Evaluation rules for grammar forms. The heading tells you whether a rule is about inspecting source or executing it. That distinction helps when a syntax error happens before the first console.log can run.
Trace a real app question
Suppose a checkout page looks up a cart field chosen by a form. You do not need an engine debugger to answer a basic question: which key did the form choose, and which object supplies that value? Make the case tiny first.
const cart = { tea: 3 };const key = "tea";console.log(cart[key]);Line 1 puts 3 under tea. Line 2 sets key to the String tea. Line 3 reads cart[key] and prints 3. Here the key needs no further conversion.
If the form supplied an object rather than a String, follow ToPropertyKey before looking for a property. Then follow the relevant [[Get]] method: own data property, inherited property, or accessor. A Proxy can show an observable property key, but its trap is app code, not a window into the engine's internals.
const cart = new Proxy({ tea: 3 }, { get(target, key, receiver) { console.log(String(key)); return Reflect.get(target, key, receiver); },});console.log(cart.tea);Line 1 wraps a cart in a Proxy. Line 2 defines a get trap; line 3 prints the key tea. Line 4 delegates the read with Reflect.get. Line 7 logs the returned 3. The two lines of output are tea and 3.
| Notation | Kind | How to read it |
|---|---|---|
ToPropertyKey(value) | Abstract operation | A spec instruction; not a global JavaScript function. |
object.[[Get]](key, receiver) | Internal method | Spec behaviour for a property read; the object can have its own implementation. |
object.[[Prototype]] | Internal slot | Specified object state, not a property named [[Prototype]]. |
? operation() | Completion shorthand | Return an abrupt completion; otherwise use its value. |
! operation() | Completion shorthand | The spec knows this particular call completes normally. |
cart[3]String(3)ToPropertyKey(3)OrdinaryGet(cart, key, receiver)cart.[[Prototype]]cart.[[Get]](key, receiver)
Put each card with its kind of instruction. Read the explanation for why it belongs there.
Common misconceptions
The spec describes results, not the exact code or memory layout an engine must use. A replay of our example is useful for learning the steps, but it does not show actual engine frames. Keep the public output and the teaching model separate.
- “I can call ToPropertyKey in JavaScript.” It is a spec operation, not a global function.
- “A property named [[Prototype]] is the internal slot.” Brackets in a String name create an ordinary property.
- “Spec ! means JavaScript not.” In algorithms it marks a known normal completion.
- “Spec ? is a conditional expression.” In algorithms it propagates an abrupt completion.
- “All [[Get]] methods are the same.” Different object kinds may supply different internal method behaviour.
Always read notation in context. In a JavaScript program, !price is logical negation; in an algorithm step, ! ToString(key) is a completion shorthand. Similarly, an ordinary property with brackets in its name does not become internal state merely because it looks like a spec label.
| Name | Spec meaning | Not the same as |
|---|---|---|
! before a spec call | Unwrap a guaranteed normal result | The JavaScript logical-not operator. |
? before a spec call | Propagate an abrupt result | The JavaScript conditional operator. |
[[Prototype]] | A spec internal slot | The normal .prototype property on functions. |
StringValue | Syntax-directed operation on a parse node | Calling String(...) on a runtime value. |
Practice exercises
Start with printed results, then connect the result to a named rule. You can run each complete starter example in the editor and compare your prediction with the real output before reading the solution.
The cart stores tea under a numeric-looking name. Predict the two values printed on the same console line. Explain why they match.
const cart = { 3: "tea" };
console.log(cart[3], cart["3"]);const cart = { 3: "tea" };
console.log(cart[3], cart["3"]);It prints tea tea: both spellings reach the same String property key.
You are reviewing the key in the previous cart example. Name the abstract operation that describes how a computed key becomes a property key. Do not type a JavaScript global name that does not exist.
const cart = { 3: "tea" };
console.log(cart[3], cart["3"]);Look up ToPropertyKey. It returns a Symbol unchanged or turns another primitive into a String key.
Read the source in order. Which value prints before cart has an own tea property? Name the object that supplies it.
const shared = { tea: 3 };
const cart = Object.create(shared);
console.log(cart.tea);const shared = { tea: 3 };
const cart = Object.create(shared);
console.log(cart.tea);It prints 3: cart has no own tea, so the lookup reaches shared.
The price object refuses conversion. Predict the console output of this complete try/catch. Explain why the line that says after is skipped.
try {
Number({ [Symbol.toPrimitive]() { throw new Error("bad price"); } });
console.log("after");
} catch (error) {
console.log(error.message);
}try {
Number({ [Symbol.toPrimitive]() { throw new Error("bad price"); } });
console.log("after");
} catch (error) {
console.log(error.message);
}It prints only bad price; after is never printed because the conversion throws.
The source spells an identifier as price and prints its value. Which kind of spec rule describes the written name? Keep it separate from the runtime operation that reads the value.
const price = 3;
console.log(price);const price = 3;
console.log(price);Static Semantics: StringValue concerns the source spelling price. Runtime evaluation obtains the value 3.
Your checkout page reads cart[field], but sometimes the form supplies the wrong field. Which operation tells you how the field becomes a property key? Use the starter example to confirm the normal case before debugging the form input.
const cart = { tea: 3 };
const field = "tea";
console.log(cart[field]);const cart = { tea: 3 };
const field = "tea";
console.log(cart[field]);Start at ToPropertyKey for computed field names. Here field is already tea, so the read prints 3. Then follow [[Get]] if the value is missing or inherited.
Check your understanding
For code questions, predict the log before looking at the choices. For notation questions, ask whether the name belongs to JavaScript source, a reusable spec algorithm, or an object's specified internals.
Question 1 of 8Where would you look for a reusable conversion from a value to a property key?
Choose an answer to see the explanation.
Question 2 of 8What do these two property reads print?
Read the code, then predictconst cart = { 3: "tea" }; console.log(cart[3], cart["3"]);Choose an answer to see the explanation.
Question 3 of 8Which part of
[[Get]]is visible as a normal property name?Choose an answer to see the explanation.
Question 4 of 8What does the inherited property read print?
Read the code, then predictconst shared = { tea: 3 }; const cart = Object.create(shared); console.log(cart.tea);Choose an answer to see the explanation.
Question 5 of 8What does
? ToNumber(arg)tell the reader?Choose an answer to see the explanation.
Question 6 of 8What does
! ToString(key)mean in ToPropertyKey?Choose an answer to see the explanation.
Question 7 of 8What does this computed property name print?
Read the code, then predictconst key = { toString: () => "tea" }; const cart = { tea: 3 }; console.log(cart[key]);Choose an answer to see the explanation.
Question 8 of 8What is a syntax-directed operation applied to?
Choose an answer to see the explanation.
Key takeaways
The specification is easier to read when you begin with a small observable question. Search for that source form, follow named operations, and stop once the relevant steps explain the output.
- Syntax names source shapes; static semantics inspect them; runtime semantics explain what running them does.
- Abstract operations such as ToPropertyKey and Call are named spec rules, not callable globals.
- Internal slots describe state; internal methods describe object behaviour. Both use double brackets in the spec.
- A preceding ? passes on abrupt completions; a preceding ! unwraps a result known to be normal here.
- Syntax-directed operations select rules for a grammar production, such as StringValue for an identifier.
Remember the one-liner.
Read one JavaScript result, find its rule, then follow only the steps needed to explain it.
Coming next: Spec types: Completion, Property Descriptor & more. That lesson gives a closer look at the records behind these operations, including the completions carried by ?.