Iteration and collections
Iterator protocols, generators, symbols, arrays, sparse arrays, Map, Set, weak collections, and iterator helpers.
The idea
Files 01–04 answered “what is a value and where does it live”. This one changes the question to how are values produced — and the answer is the language’s first protocol.
A protocol here is nothing more than an agreed set of method names. Implement the names and your object joins in everywhere the names are used; there is no registration, no base class, no interface to declare. This is why the file leans so hard on symbols: a symbol key is a name nobody can collide with by accident, which is exactly what a language-level hook needs.
Once you have a protocol for producing values one at a time, two things fall out of it. A generator is a function that implements that protocol by pausing. And the collections at the end of the file are a second answer to a question file 03 left open: what to use when your keys are not strings.
The protocol is two methods
That is the whole of it:
- an iterable has a
[Symbol.iterator]()method that hands back an iterator; - an iterator has a
.next()returning{ value, done }.
for…of, spread, array destructuring,
Array.from, and yield* do nothing but call
those two. So anything you write with those two methods works everywhere
they do — which is the payoff for learning the protocol rather than the
features.
An iterator is stateful and one-way. Take two values, walk away, come back: it is exactly where you left it, and there is no rewinding. That single fact explains most surprises when the same iterator reaches two consumers.
There is a third method, and it is the half most
people never learn: return(). A consumer that stops early —
a break, a throw, a destructuring pattern that
got enough — is required to call it. That is the mechanism that
lets a generator’s finally block run even though nobody
finished consuming it. Watch it fire while you read; it is easy to
believe cleanup is automatic and never notice who is actually
responsible for it.
Strings are iterable, and their iterator walks whole characters
rather than storage slots — which is why [..."a😀b"].length
and "a😀b".length disagree. File 33 takes that apart
properly.
A generator is a function that can pause
Calling a generator runs nothing. You get back a
paused iterator, and each .next() runs the body up to the
following yield.
The part worth slowing down for: yield is an
expression, and its value arrives from the future. It
is whatever the next .next(v) passes in. So a
yield is a two-way door — a value goes out, a value comes back — and the
two directions are offset by one call, which is why the very first
.next() argument has nowhere to land and is discarded.
Two channels leave a generator, and they are not the same channel.
Values are yielded; the function still
returns one final value at the end. for…of
and spread consume only the yields and throw the return away.
yield* is where the difference stops being academic: it
forwards every yielded value from the inner iterable and evaluates
to what that iterable returned. Substituting an array for a
generator there silently changes the result, because an array returns
undefined.
.return() and .throw() inject control flow
at the paused yield, as if the generator body had executed
a return or a throw on that line. A
finally block therefore gets to run first — and can even
refuse to stop, which file 27 makes you predict.
Because nothing runs until asked, a generator can describe an
infinite sequence at no cost. That is the payoff for all this machinery.
And a generator is both iterator and iterable — its
[Symbol.iterator]() returns itself — which is handy and is
also why you cannot restart one by iterating it twice.
Symbols are the hook mechanism
A symbol is a value whose entire purpose is that
nothing else equals it. Every Symbol("desc") call makes a
new one; the description is a label for debugging and never affects
equality. Symbol.for(key) is the exception: it returns one
shared symbol per string, retrievable forever from a global
registry.
The well-known symbols are the language’s extension
points. Implement one and your object joins a builtin protocol:
Symbol.iterator for for…of,
Symbol.toPrimitive for coercion,
Symbol.toStringTag for Object#toString,
Symbol.hasInstance for instanceof. You met the
last two in file 04 as ways a class can lie about its identity. They are
the same mechanism as iteration, and the reason they are all symbols is
collision safety.
Arrays are
objects with a length kept in sync
Two consequences run through everything in the array section:
length is writable, and an index can simply be
missing.
A hole is a missing index, not an index holding
undefined. Property-level operations can tell the two
apart; most things that iterate cannot, and the methods disagree with
each other about which they skip. Predict each one before running it —
file 31 is built from this.
The other trap is sort. The default comparator converts
every element to a string and compares character by character. That is
not a bug; it is the only ordering that works for arbitrary mixed
contents. But numbers come out wrong unless you supply a comparator,
undefined sorts last without ever reaching your comparator,
and holes go after even that. Sort is stable and sorts in
place, returning the same array it mutated.
That last point is the reason for the ES2023 copying methods —
toSorted, toReversed, with,
toSpliced. The mutating originals also return the
array, which hides the mutation at the call site.
Map and Set: keys that are not strings
Here is the thing a plain object cannot do. An object converts every
key to a string, so two different objects both become
"[object Object]" and collide. Map keeps the
key value itself and compares by identity.
That is the whole difference, and everything follows: two
identical-looking objects are two different entries; NaN
works as a key even though NaN !== NaN; and
Map preserves insertion order for every key type, where a
plain object hoists integer-like keys to the front (file 03).
Set is the same idea with membership instead of
association, and gained real set algebra in ES2025 — union,
intersection, difference,
isSubsetOf.
The weak variants hold their keys without keeping them alive, so you can attach data to an object you did not create without preventing its collection. The price is that you cannot iterate them or ask their size — the answer could change between asking and using it. That constraint is the feature, not a limitation.
Iterator helpers
The ES2025 helpers are the array methods you already know —
map, filter, take,
drop, flatMap, reduce — but lazy
and available on any iterator. No intermediate arrays, and they
work on infinite sequences, which is the protocol paying off one more
time.
Where this leaves you
You now have the shape every remaining protocol in the language follows: agreed method names, hung on symbol keys, with the consumer responsible for driving and for cleanup. Nothing about that shape is specific to iteration.
Which is exactly why file 06 is next. An async iterator is this
protocol with one change — next() returns a promise instead
of a result — and for await…of is for…of with
an await inserted. Learn the synchronous version properly
here and the asynchronous one costs you a sentence. The pausing
machinery carries over too: await is yield
with the resumption scheduled for you.
While you read
- For each iterable, name the method that produces its iterator.
- For each early exit, ask whether
return()gets called and what cleanup that runs. - In a generator, track the pause points — what goes out, and what comes back in.
- Tell apart what an iterator yields from the value it finishes with.
- For every collection key, decide whether it matched by identity or by value.
The file
Read one construct at a time, and hand-simulate every generator
before running it. Mark anything you cannot explain with
// ? in the source.
// 05 — ITERATION, GENERATORS, SYMBOLS, COLLECTIONS
//
// A reading file. Read it straight down: the comment above each construct gives
// its NAME, and `// =>` gives what the expression on its left evaluates to, so
// nothing is left hanging. Each section uses only what the lines just above it
// introduced.
//
// It still runs (`node 05-iteration-generators-collections.ts`) and prints
// nothing; the `// =>` annotations are the output, already collected.
//
// Mark anything you can't explain out loud with // ?
// ─── 1. THE ITERATION PROTOCOL ───────────────────────────────────────────────
//
// Two interfaces, and that is the entire protocol:
// an ITERABLE has a [Symbol.iterator]() that hands back an ITERATOR
// an ITERATOR has .next() returning { value, done }
//
// for-of, spread, destructuring, Array.from and yield* do nothing but call those
// two. Anything you write with those two methods therefore works everywhere they
// do. Watch for the third method, `return`, below: it is the half people don't
// know exists.
const cleanupLog: string[] = []; // declared first so the iterator below can reach it
const manualIterable = {
[Symbol.iterator](): Iterator<number> {
let i = 0;
return {
next: (): IteratorResult<number> =>
i < 3 ? { value: i++, done: false } : { value: undefined, done: true },
// OPTIONAL, and this is the interesting one. Whoever consumes you must call
// it if they stop early — a break, a throw, or a pattern that got enough.
// It is how a generator's `finally` runs when nobody finished it.
return: () => {
cleanupLog.push("cleanup ran");
return { value: undefined, done: true };
},
};
},
};
const spreadAll = [...manualIterable]; // => [0, 1, 2]
const afterFullRun = cleanupLog.length; // => 0 — running to `done` needs no cleanup call
for (const v of manualIterable) {
if (v === 1) break; // leaving early is what triggers `return`
}
const afterBreak = cleanupLog.length; // => 1
const [d1, d2] = manualIterable; // DESTRUCTURING is a consumer like any other
const firstTwo = [d1, d2]; // => [0, 1]
const afterDestructuring = cleanupLog.length; // => 2 — the pattern got enough and closed
// the iterator. This is the one people miss: destructuring does not just read, it
// finishes the iterator off.
const collected = Array.from(manualIterable); // => [0, 1, 2]
const spreadIntoCall = Math.max(...manualIterable); // => 2 — spread works on arguments too
// An iterator is stateful and one-way. Take two, walk away, come back — it is
// still exactly where you left it, and there is no rewinding.
const walker = [10, 20][Symbol.iterator]();
const pull1 = walker.next(); // => { value: 10, done: false }
const pull2 = walker.next(); // => { value: 20, done: false }
const pull3 = walker.next(); // => { value: undefined, done: true }
const pull4 = walker.next(); // => { value: undefined, done: true } — exhausted stays exhausted
// Strings are iterable, and the iterator walks whole CODE POINTS rather than
// storage slots — which is why it disagrees with `.length` on emoji (33.1).
const emoji = "a😀b";
const storageSlots = emoji.length; // => 4
const codePoints = [...emoji].length; // => 3
const characters = [...emoji]; // => ["a", "😀", "b"]
// ─── 2. GENERATORS ───────────────────────────────────────────────────────────
//
// A generator is a function that can pause. Calling it runs NOTHING — you get a
// paused iterator back, and each .next() runs until the following yield.
//
// The part worth slowing down for: `yield` is an expression with a value, and
// that value arrives from the FUTURE. It is whatever the next .next(v) passes in.
function* simple() {
yield 1;
yield 2;
return "returned, NOT yielded";
}
const yielded = [...simple()]; // => [1, 2] — a for-of/spread consumer stops AT `done`
// and throws the return value away. Only .next() ever shows it to you.
const g = simple();
const step1 = g.next(); // => { value: 1, done: false }
const step2 = g.next(); // => { value: 2, done: false }
const step3 = g.next(); // => { value: "returned, NOT yielded", done: true }
const step4 = g.next(); // => { value: undefined, done: true }
// So yield is a two-way door: a value goes out, and a value comes back in. The
// two are offset by one call, which is why the first .next() argument has nowhere
// to land and is silently discarded.
const receivedLog: string[] = [];
function* twoWay(): Generator<string, void, string> {
const received = yield "first"; // the value of this expression arrives later
receivedLog.push(received);
yield `echo ${received}`;
}
const tw = twoWay();
const opened = tw.next(); // => { value: "first", done: false } — runs to the first yield
const answered = tw.next("sent in"); // => { value: "echo sent in", done: false }
const whatItGot = receivedLog; // => ["sent in"] — the argument became the yield's value
// DELEGATION — `yield*` forwards every value the inner iterable yields, and
// evaluates to what the inner generator RETURNED. Two different channels.
function* inner() {
yield "a";
return "inner return";
}
function* outer() {
const innerResult = yield* inner();
yield innerResult;
yield* [1, 2]; // any iterable works. An array "returns" undefined, so swapping an
// array in for a generator here would silently change the value above.
}
const delegated = [...outer()]; // => ["a", "inner return", 1, 2]
// .return() and .throw() inject control flow at the paused yield.
const finallyLog: string[] = [];
function* withCleanup(): Generator<number, string> {
try {
yield 1;
yield 2;
} finally {
finallyLog.push("finally ran");
}
return "ran to completion";
}
const wc = withCleanup();
wc.next();
const returned = wc.return("early"); // => { value: "early", done: true }
const cleanupHappened = finallyLog; // => ["finally ran"] — asking it to stop still lets
// `finally` run first, and a `finally` can even refuse to stop (27.4)
function* catches() {
try {
yield 1;
} catch (e) {
yield `caught ${e}`;
}
}
const ca = catches();
ca.next();
const thrownIn = ca.throw("injected"); // => { value: "caught injected", done: false }
// The throw happens AT the paused yield, so the generator's own try/catch sees it.
// Because nothing runs until asked, a generator can describe an infinite sequence
// and cost nothing. This is the payoff for all the machinery.
function* naturals(): Generator<number> {
let n = 0;
while (true) yield n++;
}
function* take<T>(src: Iterable<T>, count: number) {
let i = 0;
for (const v of src) {
if (i++ >= count) return;
yield v;
}
}
const firstFive = [...take(naturals(), 5)]; // => [0, 1, 2, 3, 4]
// A generator is both the iterator AND the iterable: its [Symbol.iterator]
// returns itself. Handy — and the reason iterating one twice does not restart it.
const selfIterable = naturals();
const isItsOwnIterable = selfIterable[Symbol.iterator]() === selfIterable; // => true
// ─── 3. SYMBOLS ──────────────────────────────────────────────────────────────
//
// A symbol is a value whose only property is that nothing else equals it. That
// makes it a key nobody can collide with by accident — exactly what you need to
// hang language-level hooks on ordinary objects.
const unique1 = Symbol("desc");
const unique2 = Symbol("desc");
// @ts-expect-error TS2367: TS can prove these two symbols are never equal
const sameDescription = unique1 === unique2; // => false — the description is a label,
const describedAs = unique1.description; // => "desc" // not an identity
const printed = unique1.toString(); // => "Symbol(desc)" — and `${unique1}` would THROW
// Symbol.for shares one symbol per string, globally and forever. That difference
// from Symbol() is why a registered symbol is rejected by WeakMap (32.3).
const registeredMatch = Symbol.for("shared") === Symbol.for("shared"); // => true
const registryKey = Symbol.keyFor(Symbol.for("shared")); // => "shared"
const unregisteredKey = Symbol.keyFor(unique1); // => undefined — not in the registry
// The WELL-KNOWN SYMBOLS are the language's extension points. Implement one and
// your object joins a built-in protocol.
class Protocols {
[Symbol.iterator]() { return [1][Symbol.iterator](); } // for-of
[Symbol.toPrimitive](hint: string) { return hint === "number" ? 1 : "str"; } // coercion
get [Symbol.toStringTag]() { return "Protocols"; } // Object#toString
static [Symbol.hasInstance](_v: unknown) { return true; } // instanceof
}
const pr = new Protocols();
const joinedForOf = [...pr]; // => [1]
const joinedNumeric = +pr; // => 1 — the "number" hint
const joinedString = `${pr}`; // => "str" — the "string"/"default" hint
const joinedTag = Object.prototype.toString.call(pr); // => "[object Protocols]"
// ─── 4. ARRAYS ───────────────────────────────────────────────────────────────
//
// An array is an object with numeric keys and a `length` kept in sync. Two
// consequences run through everything below: `length` is writable, and an index
// can simply be missing.
const arr = [1, 2, 3, 4, 5];
// SORT converts everything to strings and compares them character by character.
// Not a bug — it is the only ordering that works for arbitrary mixed contents —
// but it means numbers come out wrong unless you say otherwise.
const lexicographic = [10, 9, 1, 100].sort(); // => [1, 10, 100, 9]
const numeric = [10, 9, 1, 100].sort((a, b) => a - b); // => [1, 9, 10, 100]
const undefinedLast = [undefined, 3, 1].sort(); // => [1, 3, undefined] — `undefined`
// is moved to the end without ever reaching your comparator; holes go after even that
const toSort = [3, 1];
const sortsInPlace = toSort.sort() === toSort; // => true — it returns the SAME array,
// which is how the mutation hides in a chain. Sort has been stable since ES2019.
// MUTATING METHODS. Each one here works on a fresh copy, so the results stay
// comparable — and each returns something other than the array it changed.
const pushed = [...arr].push(6); // => 6 — the new LENGTH, not the array
const popped = [...arr].pop(); // => 5 — the removed element
const shifted = [...arr].shift(); // => 1 — the removed first element
const unshifted = [...arr].unshift(0); // => 6 — the new length again
const spliced = [...arr].splice(1, 2); // => [2, 3] — what was REMOVED
const reversed = [...arr].reverse(); // => [5, 4, 3, 2, 1]
const filled = [...arr].fill(0, 1, 3); // => [1, 0, 0, 4, 5]
const copiedWithin = [...arr].copyWithin(0, 3); // => [4, 5, 3, 4, 5]
// The ES2023 COPYING VERSIONS. Same operations, new array. Reach for these by
// default, since the mutating ones also return a value and hide the damage.
const toSorted = arr.toSorted((a, b) => b - a); // => [5, 4, 3, 2, 1]
const toReversed = arr.toReversed(); // => [5, 4, 3, 2, 1]
const withOne = arr.with(0, 99); // => [99, 2, 3, 4, 5]
const toSpliced = arr.toSpliced(1, 2); // => [1, 4, 5]
const originalIntact = arr; // => [1, 2, 3, 4, 5] — none of the four touched it
// ITERATION AND TRANSFORMATION.
const mapped = arr.map((v) => v * 2); // => [2, 4, 6, 8, 10]
const filtered = arr.filter((v) => v % 2); // => [1, 3, 5]
const reduced = arr.reduce((s, v) => s + v, 0); // => 15
const reducedRight = arr.reduceRight((s, v) => s + v); // => 15 — same sum, other direction
const flattened = [[1], [[2]]].flat(); // => [1, [2]] — one level by default
const flattenedTwice = [[1], [[2]]].flat(2); // => [1, 2]
const flatMapped = arr.flatMap((v) => [v, v]); // => [1, 1, 2, 2, 3, 3, 4, 4, 5, 5]
const found = arr.find((v) => v > 3); // => 4 — the VALUE
const foundIndex = arr.findIndex((v) => v > 3); // => 3 — the INDEX
const foundLast = arr.findLast((v) => v < 3); // => 2
const someMatch = arr.some((v) => v > 4); // => true
const allMatch = arr.every((v) => v > 0); // => true
const included = arr.includes(3); // => true — and unlike indexOf, it finds NaN
const indexed = arr.indexOf(3); // => 2
const lastItem = arr.at(-1); // => 5 — negative indices count from the end
const sliced = arr.slice(-2); // => [4, 5]
const joined = arr.join("-"); // => "1-2-3-4-5"
const entries = [...arr.entries()]; // => [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5]]
const built = Array.of(1, 2); // => [1, 2] — unlike Array(2), which builds a length-2 hole
const generated = Array.from({ length: 3 }, (_, i) => i); // => [0, 1, 2] — from array-like
const isArray = Array.isArray(arr); // => true
// A HOLE is a missing index, not an index holding undefined. Property-level
// operations can tell the difference; anything that iterates cannot. Predict each
// of these before reading — this is exercise 31.1.
const sparse = [1, , 3];
const sparseLength = sparse.length; // => 3
const holeIsAbsent = 1 in sparse; // => false — the key does not exist
const holeReadsUndefined = sparse[1]; // => undefined — reading a missing key
const mappedKeepsHoles = sparse.map((v) => v); // => [1, <1 empty item>, 3]
const spreadFillsHoles = [...sparse]; // => [1, undefined, 3] — iteration cannot see holes
const keysSkipHoles = Object.keys(sparse); // => ["0", "2"]
const forEachVisits: (number | undefined)[] = []; // the hole makes the element type wider
sparse.forEach((v) => forEachVisits.push(v));
const forEachSkipsHoles = forEachVisits; // => [1, 3] — the callback never ran for index 1
// ─── 5. MAP, SET, AND THE WEAK VARIANTS ──────────────────────────────────────
//
// The thing objects cannot do: hold a key that is not a string. A plain object
// converts every key, so two different objects both become "[object Object]" and
// collide. A Map keeps the key itself and compares by identity.
const objKey = {};
const map = new Map<string | object, number>([["a", 1]]);
map.set(objKey, 2).set("b", 3); // `set` returns the map, so it chains
const mapGet = map.get("a"); // => 1
const mapHasObject = map.has(objKey); // => true — the same object, by identity
const mapSize = map.size; // => 3 — a property, not a method
const mapKeys = [...map.keys()]; // => ["a", {}, "b"] — insertion order, every key type
const mapEntries = [...map.entries()]; // => [["a", 1], [{}, 2], ["b", 3]]
map.delete("a");
const afterDelete = [...map.keys()]; // => [{}, "b"]
const lookalikeMiss = new Map([[{}, 1]]).get({}); // => undefined — two different objects
// that print the same are still two different keys
const set = new Set([1, 2, 2, 3]);
const setSize = set.size; // => 3 — the duplicate was dropped on the way in
const setHas = set.has(2); // => true
const setValues = [...set]; // => [1, 2, 3]
// ES2025 set operations.
const united = [...set.union(new Set([4]))]; // => [1, 2, 3, 4]
const intersected = [...set.intersection(new Set([1]))]; // => [1]
const differenced = [...set.difference(new Set([1]))]; // => [2, 3]
const isSubset = set.isSubsetOf(new Set([1, 2, 3])); // => true
// A Map keeps insertion order for every key type. A plain object reorders
// integer-like keys to the front (file 03, section 7).
const mapOrder = [...new Map([["b", 1], ["2", 2], ["a", 3]]).keys()]; // => ["b", "2", "a"]
const objectOrder = Object.keys({ b: 1, 2: 2, a: 3 }); // => ["2", "b", "a"]
// WEAK COLLECTIONS do not keep their keys alive, so you can attach data to an
// object without preventing its collection. The price: you cannot iterate them or
// ask their size, because the answer could change between asking and using it.
const weakMap = new WeakMap<object, string>();
weakMap.set(objKey, "attached without owning");
const weakRead = weakMap.get(objKey); // => "attached without owning"
const weakSetHas = new WeakSet([objKey]).has(objKey); // => true
const stillAlive = new WeakRef(objKey).deref() !== undefined; // => true — while something
// else still references it, which `objKey` above does
// ─── 6. ITERATOR HELPERS (ES2025) ────────────────────────────────────────────
//
// The array methods you already know, but lazy and on any iterator — so no
// intermediate arrays, and they work on infinite sequences.
const lazySquares = naturals().take(5).map((n) => n * n).filter((n) => n % 2 === 0).toArray();
// => [0, 4, 16] — `naturals()` is infinite, and only five values were ever produced
const droppedThenTaken = naturals().drop(2).take(3).reduce((a, b) => a + b); // => 9
const fromArray = Iterator.from([1, 2, 3]).flatMap((n) => [n, -n]).toArray();
// => [1, -1, 2, -2, 3, -3]