A tour of the abstract operations
Follow JavaScript's named specification rules for converting objects, comparing values, stepping through iterators, and choosing array results.
- 01Trace a conversionPredict which object hook runs and distinguish primitive, property-key, and numeric conversions.
- 02Explain comparisonsFollow the different paths taken by loose equality and relational comparison, including visible conversion order.
- 03Read iteration and constructionExplain iterator results, early closing, and how an array method chooses its result constructor.
One rule used in many places
How to read ECMAScript introduced named rules inside the language standard. Now we follow those rules through code you can actually run. An abstract operation is a reusable rule in the specification, not a global function exposed to your program.
const price = { valueOf() { return 3; } };
console.log(price + 2);Line 1 makes a price object whose valueOf returns 3. Line 2 adds 2 and prints 5. The interesting step is hidden between those lines: addition first asks the object for a primitive value. A primitive is a non-object value such as a Number, String, or BigInt.
An abstract operation gives a name to a required piece of language behavior. To understand one, start with the JavaScript expression, then follow only the spec operations needed to explain its observable result.
The previous lesson, Internal methods & exotic objects, explained how objects respond to property access. Here we ask what happens when those objects are used as keys, numbers, comparison operands, iterables, or constructors for derived arrays. The specification describes the result, not the engine's machine code.
ToPrimitive and ordinary conversion
ToPrimitive asks for a non-object value. With no special hook, it delegates to OrdinaryToPrimitive. A hint tells the conversion which kind of result to try first; it does not force the returned value to have that type.
const price = { valueOf() { return 3; } };
console.log(price + 2);Line 1 defines valueOf to return the Number 3. Line 2 prints 5 because the default conversion for this ordinary object tries valueOf first. Addition then adds two Numbers. See the exact ToPrimitive rule for the hook and fallback paths.
const calls = [];
const price = {
valueOf() { calls.push("valueOf"); return {}; },
toString() { calls.push("toString"); return "3"; }
};
console.log(Number(price), calls.join(","));Line 1 starts an empty call log. Line 3 logs valueOf but returns another object, so it cannot finish primitive conversion. Line 4 logs toString and returns the String 3. Line 6 calls Number on the result and prints 3 valueOf,toString. Both methods must fail to produce a primitive before OrdinaryToPrimitive throws a TypeError; it does not accept an object as the final answer.
Your phone stores more about a contact than just a name. To search, you first need a name you can type.
- In real life: A contact has details
- In JavaScript: An object has properties
- In real life: A saved name identifies it
- In JavaScript: A primitive can name a key
- In real life: The phone tries a saved name
- In JavaScript: Conversion tries an available method
Where the analogy stops: An object can define conversion hooks and return Symbols; a phone contact lookup cannot model those language rules.
Choosing a conversion hint
The order changes when a string result is preferred. String(object) tries toString before valueOf in the ordinary fallback. For a number hint, OrdinaryToPrimitive tries them in the other order. A method returning an object causes the other candidate to be tried.
const calls = [];
const key = {
valueOf() { calls.push("valueOf"); return 3; },
toString() { calls.push("toString"); return "tea"; }
};
console.log(String(key), calls.join(","));Line 1 creates the log. Line 3 could return 3, but line 4's toString runs first with a string hint. Line 6 prints tea toString. Nothing called valueOf this time.
const price = {
[Symbol.toPrimitive](hint) {
console.log(hint);
return 3;
}
};
console.log(price + 2);
console.log(Number(price));Lines 2 through 5 define Symbol.toPrimitive. Line 7 prints default and then 5: addition requested a primitive without a preferred type. Line 8 prints number and then 3 because Number(price) requests numeric conversion. The hook is tried before OrdinaryToPrimitive; if it returns another object, conversion throws rather than trying valueOf afterward.
Replay of instrumented JavaScript example code, not an engine debugger.
script
function total(price) { return price + 2;} [Symbol.toPrimitive](hint) { console.log(hint); return 3; }};console.log(total(price));Step through the instrumented total function to see the hook receive default, return 3, and let addition return 5. The replay records actual calls of lesson code; it is not an engine debugger. This is a useful way to find which part of a surprising expression has side effects.
ToPropertyKey makes a usable name
ToPropertyKey converts a value to a String or a Symbol. It asks ToPrimitive for a string hint, keeps a Symbol primitive as it is, and converts any other primitive to a String. This is why a Number written between brackets can name a property stored under a String.
const cart = { 3: "tea" };
console.log(cart[3], cart["3"]);Line 1 stores tea under the String key "3". Line 2 reads it with both 3 and "3", then prints tea tea. A numeric index on an ordinary object does not create a separate Number-keyed property.
const cart = {};
const key = {
[Symbol.toPrimitive](hint) {
console.log(hint);
return 3;
}
};
cart[key] = "tea";
console.log(cart["3"]);Line 1 creates the cart. Lines 2 through 7 create a key object that logs its hint and returns 3. Line 8 prints string during assignment; the primitive Number becomes the String key "3". Line 9 prints tea when reading it back. The current ToPropertyKey steps make that path explicit.
const key = Symbol("tea");
const cart = { [key]: 3 };
console.log(cart[key], cart["Symbol(tea)"]);Line 1 creates one Symbol. Line 2 stores 3 under it. Line 3 prints 3 undefined: the Symbol key and the String "Symbol(tea)" are different. A description helps humans identify a Symbol; it is not a substitute for the Symbol as a key.
ToNumeric preserves BigInt
ToNumeric requests a primitive with a number hint, then returns it unchanged if it is a BigInt. Otherwise it applies ToNumber. This named rule explains why a numeric operator can work with BigInt without silently changing it into an imprecise Number.
let price = { valueOf() { return 3n; } };
console.log(typeof ++price, price === 4n);Line 1 creates an object whose valueOf returns 3n. Line 2 increments the variable after conversion, then prints bigint true: the incremented value is 4n. Unlike a read-only addition, prefix increment also assigns the result back to price. The ToNumeric steps preserve the BigInt branch.
console.log(3n < 4);
try { console.log(3n + 4); }
catch (error) { console.log(error.name); }Line 1 prints true because relational comparison can compare BigInt and Number values. Line 2 tries to add 3n to 4; line 3 catches the TypeError and prints TypeError. ToNumeric does not mean every operator accepts mixed numeric types.
function comparePrice(input) { const price = { valueOf() { return 3; } }; return price < input;}console.log(comparePrice(4));trueThe object supplies 3. Comparing 3 < 4 prints true. Reset restores the limit to 4.
Change only the limit. The visible function converts an object that supplies 3 before comparing it to your selected Number; Reset restores 4 and true. It is the actual JavaScript comparison, not a simulated interpreter for ToNumeric.
IsLooselyEqual and IsLessThan ask different questions
IsLooselyEqual defines == using cases for pairs of types. A Number versus a String converts the String to Number; an object versus a Number or String converts the object to a primitive. === follows the separate IsStrictlyEqual rule and does not coerce different types.
console.log("3" == 3);
console.log("3" === 3);Line 1 converts "3" for loose equality and prints true. Line 2 uses strict equality and prints false, because String and Number are different types. For a full introduction to these operators, see Equality & sameness.
const price = { valueOf() { console.log("valueOf"); return 3; } };
console.log(price == 3);
console.log(price == null);Line 1 gives price a logging valueOf method. Line 2 prints valueOf, then true, because comparison with 3 asks for a primitive. Line 3 prints false without another conversion: an ordinary object compared with null matches neither the null/undefined special case nor an object-to-primitive case. The browser's special legacy document.all case is not an ordinary object and is not part of this example.
IsLessThan describes relational comparison. If both converted values are Strings, it compares their UTF-16 code units in order; otherwise it follows numeric paths, with special BigInt/String cases. It can return a spec-level undefined for unordered inputs such as NaN; the JavaScript comparison operators turn that into a Boolean result.
console.log("10" < "2");
console.log("10" < 2);Line 1 prints true: the String "10" begins with a code unit that comes before the one in "2". Line 2 prints false: a String and Number are compared numerically, and ten is not less than two. Do not use a lexical comparison as a price sorter.
const left = { valueOf() { console.log("left"); return 3; } };
const right = { valueOf() { console.log("right"); return 4; } };
console.log(left < right);
console.log(left > right);Lines 1 and 2 define logging operands. Line 3 prints left, right, then true. Line 4 prints left, right, then false. The IsLessThan leftFirst argument preserves this visible order even when > describes its test by passing operands in the other order.
console.log(NaN < 3, NaN >= 3);Line 1 prints false false. Neither less-than nor greater-than-or-equal makes NaN comparable; the spec's internal undefined comparison result is handled as false by both JavaScript operators. Read the full cases in IsLooselyEqual and IsLessThan when a real input differs from these small examples.
| Rule | Question | Important difference |
|---|---|---|
| IsLooselyEqual | The == comparison | Converts by type pair, including object versus primitive |
| IsStrictlyEqual | The === comparison | Different types cannot compare equal |
| IsLessThan | The < comparison; also helps define > | Primitive conversion order is controlled by leftFirst |
GetIterator and IteratorStep move through values
An iterable provides Symbol.iterator. GetIterator calls that method in the synchronous case and creates an Iterator Record holding the iterator object, its next method, and its done state. A loop then asks the iterator for successive result objects.
const items = ["tea", "milk"];
for (const item of items) console.log(item);Line 1 creates an array of tea and milk. Line 2 uses for-of to print tea and then milk. Each iteration requests another result until its done field indicates completion.
const items = {
[Symbol.iterator]() {
console.log("iterator");
let index = 0;
return {
next() {
console.log("next");
return index++ === 0 ? { value: "tea", done: false } : { done: true };
},
return() { console.log("return"); return { done: true }; }
};
}
};
for (const item of items) { console.log(item); break; }Line 2 defines the iterator method and line 3 logs iterator. Line 6's next method logs next; line 8 returns a result containing tea with done false on the first call. Line 14 prints tea, then breaks. Line 10's return method logs return during the early close. The output is iterator, next, tea, return.
IteratorStep calls next and checks done. It returns the whole result object or the spec marker DONE, not its value. IteratorStepValue additionally reads the value if not done. These names are different operations in the current spec. A normal exhausted iterator need not call return, unlike an early break.
const items = {
[Symbol.iterator]() {
let count = 0;
return {
next() { console.log("next"); return count++ ? { done: true } : { value: "tea", done: false }; },
return() { console.log("return"); return { done: true }; }
};
}
};
console.log([...items].join(","));Line 1 makes an iterable. Line 5 logs next for each request. Line 6 defines a return method, but line 10 spreads the whole iterable, so it prints next, next, then tea. The spread completes normally; it does not call return.
const items = {
[Symbol.iterator]() {
return { next() { return { get done() { console.log("done"); return true; }, get value() { console.log("value"); return "tea"; } }; } };
}
};
console.log([...items].length);Line 3 returns a result with logging getters for done and value. Line 6 spreads the iterable and prints 0. The other output is only done: once the result is finished, the value getter is never read. This is direct evidence for the step-then-value distinction.
Replay of instrumented JavaScript iterator code, not an engine debugger.
script
function first(items) { for (const item of items) { console.log(item); break; }} [Symbol.iterator]() { console.log("iterator"); return { next() { console.log("next"); return { value: "tea", done: false }; }, return() { console.log("return"); return { done: true }; } }; }};first(items);The replay runs first with a real iterable. Step through the iterator-method call, next result, value binding, and early return. It is a replay of instrumented example code, not a display of engine frames.
| Operation | Work | Result |
|---|---|---|
| GetIterator | Find and call the iterator method | Produces an Iterator Record, not an Array |
| IteratorStep | Call next and inspect done | Returns a result object or the spec marker DONE |
| IteratorStepValue | Step, then read value when not done | Returns a value or DONE; it is not an alias for IteratorStep |
| IteratorClose | Ask return to run on early exit | A normal exhaustion does not call return |
SpeciesConstructor chooses a derived result
SpeciesConstructor is a spec rule that reads an object's constructor and that constructor's Symbol.species property. When an operation uses this rule, it chooses a constructor for a new related object. Some Array methods such as map use an Array-specific species creation path with the same idea; not every method or new copy uses species.
class Orders extends Array {}
const orders = new Orders("tea", "milk");
console.log(orders.map(item => item.toUpperCase()) instanceof Orders);Line 1 makes an Array subclass. Line 2 creates an Orders instance. Line 3 maps it to a new array and prints true: the inherited default species returns the subclass constructor in this case.
class Orders extends Array {
static get [Symbol.species]() {
console.log("species");
return Array;
}
}
const orders = new Orders("tea", "milk");
const copy = orders.map(item => item.toUpperCase());
console.log(copy instanceof Orders, copy instanceof Array, copy.join(","));Lines 2 through 5 replace the species choice with Array and log species when read. Line 7 creates an Orders input. Line 8 maps its elements. Line 9 prints false true TEA,MILK: the output is a plain Array, not Orders. This concerns the new result, not the type of the original instance.
class Orders extends Array {
static get [Symbol.species]() { return null; }
}
const orders = new Orders("tea");
console.log(orders.slice() instanceof Orders);Line 2 returns null for species. Line 4 creates an Orders input. Line 5 prints false because the default Array constructor makes the slice result. In the general SpeciesConstructor rule, undefined or null species selects the supplied default constructor; an invalid non-constructor throws.
class Orders extends Array {
static get [Symbol.species]() { return 3; }
}
try { new Orders("tea").map(item => item); }
catch (error) { console.log(error.name); }Line 2 supplies the Number 3 instead of a constructor. Line 4 asks map for a new result; line 5 catches the error and prints TypeError. In application code, prefer an explicit Array conversion unless you intentionally need subclassed results. Species is a compatibility feature, not a general factory hook for all objects.
Trace a checkout bug
Suppose a checkout page receives a computed field from a form helper. A plain String key is easy to inspect, but an object key can run application code during conversion. Reduce the case to a cart and one field before blaming the framework or the browser.
const cart = { tea: 3 };
const field = { toString() { return "tea"; } };
console.log(cart[field]);Line 1 creates a cart with tea priced at 3. Line 2 creates a field object whose toString returns tea. Line 3 prints 3. ToPropertyKey first asks for a primitive using a string hint; the converted key then finds cart.tea. This is an observable lookup, not a call to a global function named ToPropertyKey.
const cart = ["tea", "milk"];
for (const item of cart) {
if (item === "tea") { console.log(item); break; }
}Line 1 creates a cart array. Line 2 begins iteration. Line 3 prints tea and breaks immediately, so milk is not printed. If your real cart is a custom iterable, test its next and return methods to see what cleanup an early exit performs.
For more conversion examples, visit Type coercion. For the broader comparison rules, visit Equality & sameness. Here the goal is to find the named operation that explains one surprising step, and then verify the result with a tiny program.
- Object becomes a primitive for addition
- Object becomes a property key
- Compare
"3" == 3 - Compare
"10" < "2" - Find
Symbol.iterator - Call
nextand readdone
Sort each situation by its primary rule, then read why it fits.
Common misconceptions
A specification name is a tool for explanation. It does not become a callable JavaScript global, and it does not promise a specific internal function call in your engine. Ask which behavior is visible to your code.
const price = { valueOf() { return 3; } };
console.log(price == 3, price === 3);Line 1 creates an object that supplies 3 when converted. Line 2 prints true false. Loose equality converts this object against a Number; strict equality keeps the type difference. Do not replace this rule with the slogan that == always converts everything.
- "A hint guarantees the result type." It changes preferred method order; a hook can return another primitive type.
- "Every == call converts both sides." Same-type values use strict comparison; object versus null does not call valueOf.
- "Greater-than converts the right object first." leftFirst preserves the source's left-to-right effects.
- "IteratorStep returns the value." It returns a result object or DONE; IteratorStepValue reads value.
- "Species changes the input array." It influences certain newly created results only.
An iterator's normal exhaustion and an early break are also different endings. A missing return method is allowed, but a defined one may be called during early closing. Keep this distinction in mind when an iterable owns a resource such as an open stream.
| Rule | Output | What starts it |
|---|---|---|
| ToPrimitive | Object to a non-object value | A preferred hint; a hook can return any primitive |
| ToPropertyKey | Value to a String or Symbol key | First uses ToPrimitive with a string hint |
| ToNumeric | Value to Number or BigInt | First uses ToPrimitive with a number hint |
Practice exercises
Predict before running the starter code. Each question asks for a real output rather than a spec label alone. Once you know the output, name the operation that explains it.
A receipt stores its price in an object with valueOf. Read the code and type the number printed. Which method supplied the first operand?
const price = { valueOf() { return 3; } };
console.log(price + 2);const price = { valueOf() { return 3; } };
console.log(price + 2);It prints 5. The object supplies 3, then addition adds 2.
The cart stores tea under a numeric-looking key. Predict the one word logged when code reads with a Number. What does ToPropertyKey produce?
const cart = { "3": "tea" };
console.log(cart[3]);const cart = { "3": "tea" };
console.log(cart[3]);It prints tea. Number 3 becomes the String key 3 for this lookup.
A form supplies the text "3" while a price is stored as the Number 3. Predict both Boolean outputs. Would you use these two operators interchangeably in checkout code?
console.log("3" == 3, "3" === 3);console.log("3" == 3, "3" === 3);It prints true false. Loose equality converts the String; strict equality does not.
The app scans a shopping list and stops at the first item. What word is logged? Explain why the second item is never printed.
const items = ["tea", "milk"];
for (const item of items) { console.log(item); break; }const items = ["tea", "milk"];
for (const item of items) { console.log(item); break; }It prints tea only. The break prevents a second body run.
Your app subclasses Array for orders, but map should return a plain array. The subclass provides a species getter. Does the mapped result inherit from Orders?
class Orders extends Array { static get [Symbol.species]() { return Array; } }
console.log(new Orders("tea").map(item => item) instanceof Orders);class Orders extends Array { static get [Symbol.species]() { return Array; } }
console.log(new Orders("tea").map(item => item) instanceof Orders);It prints false: map creates an Array rather than an Orders result.
A checkout form hands you a field object instead of a String. The cart displays the wrong item. Reduce the lookup to this example and predict what prints before debugging the full UI.
const cart = { tea: 3 };
const key = { toString() { return "tea"; } };
console.log(cart[key]);const cart = { tea: 3 };
const key = { toString() { return "tea"; } };
console.log(cart[key]);It prints 3. The field object becomes the tea key, and the cart holds 3 there.
Check your understanding
Each question has one answer. For code questions, run the small program and match its output exactly; for concept questions, say which named rule handles the operation before choosing.
Question 1 of 7When an object has no Symbol.toPrimitive hook, which method does OrdinaryToPrimitive try first with a number hint?
Choose an answer to see the explanation.
Question 2 of 7What does this computed key print?
Read the code, then predictconst cart = { 3: "tea" }; console.log(cart[3]);Choose an answer to see the explanation.
Question 3 of 7What does ToNumeric return if ToPrimitive produces 3n?
Choose an answer to see the explanation.
Question 4 of 7What do these two comparisons print?
Read the code, then predictconsole.log("10" < "2", "10" < 2);Choose an answer to see the explanation.
Question 5 of 7Why does IsLessThan take leftFirst?
Choose an answer to see the explanation.
Question 6 of 7What does IteratorStep return before the value is read?
Choose an answer to see the explanation.
Question 7 of 7What does the mapped subclass result inherit from?
Read the code, then predictclass Orders extends Array { static get [Symbol.species]() { return Array; } } console.log(new Orders("tea").map(item => item) instanceof Orders);Choose an answer to see the explanation.
If an answer surprised you, return to the nearest runnable example. Conversion order and iterator cleanup are easier to remember after watching the actual console output.
Key takeaways
Start at a JavaScript expression and trace only the required operations. The named rules describe language behavior you can test without claiming to inspect engine internals.
- ToPrimitive consults Symbol.toPrimitive first, then ordinary methods in hint-dependent order.
- ToPropertyKey returns a String or Symbol; ToNumeric returns a Number or BigInt.
- IsLooselyEqual and IsLessThan have different conversion cases; leftFirst preserves visible comparison order.
- GetIterator obtains the iterator; IteratorStep checks done; IteratorStepValue reads a value when available.
- SpeciesConstructor explains how a suitable constructor can be selected for certain derived results.
Remember the one-liner.
An abstract operation is a named rule for required behavior, not a function your page can call.
Coming next: Lexical grammar & ASI. It asks how source characters become tokens before any of these operations can run.