JavaScript Part 2

Iterators, Generators and Symbols

The iteration protocol, writing iterators and generators by hand, lazy sequences, async iterators and symbols.

Lesson 0428 min read#iterators#generators#symbols

On this page

  1. Words used in this lesson
  2. Part 1. The iteration protocol
  3. Part 2. Writing an iterator by hand
  4. Part 3. Generators: iterators that pause
  5. Part 4. Lazy and infinite sequences
  6. Part 5. Two-way yield: sending values in
  7. Part 6. Async iterators and for await
  8. Part 7. Symbols
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: an iterator is any object with a next() method that hands out values one at a time. for...of, spread and destructuring all use it, generators (function*) are a short way to write one, and symbols are the unique keys that make the whole protocol possible.

This lesson explains the machinery behind for (const x of something). You will write an iterator by hand, then see how a generator pauses and resumes. After that you will build lazy and infinite sequences, send values into a generator, and loop over data that arrives over time with for await. The last part covers symbols, because Symbol.iterator is a symbol.

You should know closures (JavaScript, Lesson 10) and how this works in a method (Lesson 1 of this series). Objects and the prototype chain are in Lesson 0. Async generators use promises, which Lesson 2 covers. For when promise callbacks run, see JavaScript, Lesson 14.

What I ran / checked. Every code block below was run with Node v22.22.0 on Linux, and the output is pasted from those runs. The memory numbers come from process.memoryUsage() on this machine and will differ on yours. Iterator helper methods (.map, .take, .toArray on iterators) worked in this Node version without flags. I read no specification text for this lesson: the claims are what the code showed. The fetch and readline examples run in Node only, not in a browser.

Words used in this lesson

  • Iterable is an object that can be looped over. It has a method stored under the key Symbol.iterator. That method returns an iterator.
  • Iterator is an object with a next() method. Each call returns a result object { value, done }.
  • Iteration protocol is the pair of rules above: how an iterable gives you an iterator, and how the iterator hands out values. "Protocol" just means "an agreement about method names and return shapes".
  • Generator function is a function written as function*. Calling it does not run the body. It returns a generator object, which is an iterator you can pause.
  • yield pauses a generator and hands a value out. The next next() call resumes from the same spot.
  • Lazy means the next value is computed only when someone asks for it. The opposite is eager: everything is computed up front.
  • Async iterator is the same idea, but next() returns a promise. You loop over it with for await...of.
  • Symbol is a primitive type whose values are always unique. It is mostly used as a property key that cannot clash with any string key.
  • Well-known symbols are built-in symbols such as Symbol.iterator and Symbol.toPrimitive. The language looks for them to customise its own behaviour.

Part 1. The iteration protocol

Two small rules

Everything in this lesson sits on two rules.

  1. An iterable has a method at Symbol.iterator. Calling it returns an iterator.
  2. An iterator has a next() method. Each call returns { value, done }. While there is more data, done is false. After the last value, done is true.

Arrays already follow these rules. Let us do by hand what for...of does:

JavaScript
const arr = ['a', 'b'];const it = arr[Symbol.iterator]();console.log(it.next());console.log(it.next());console.log(it.next());console.log(it.next());console.log(typeof arr[Symbol.iterator], typeof {}[Symbol.iterator]);console.log(Object.prototype.toString.call(it));

Output:

TEXT
{ value: 'a', done: false }{ value: 'b', done: false }{ value: undefined, done: true }{ value: undefined, done: true }function undefined[object Array Iterator]

Read it line by line:

  • Two calls give the two values.
  • The third call gives done: true. That is the signal "no more". The value is undefined.
  • Calling next() again after the end keeps returning done: true. A well-behaved iterator stays finished.
  • A plain object {} has no Symbol.iterator, so typeof gives undefined. This is why plain objects cannot be looped with for...of.

What for...of does for you

for (const x of thing) { ... } is roughly this:

  1. Call thing[Symbol.iterator]() once to get an iterator.
  2. Call next(). If done is true, stop.
  3. Otherwise put value in x, run the body, and go back to step 2.

If you leave the loop early (break, return, or a thrown error), the loop calls the iterator's return() method, if it has one. That gives the iterator a chance to clean up (close a file, release a lock). We will see this in Part 2.

Arrays, strings (by whole character), Map (as [key, value] pairs), Set, NodeList, typed arrays and generator objects are iterable. A plain object {} is not.

for...of versus for...in

They look alike and do different things. I ran both on the same data:

JavaScript
class P { constructor() { this.own = 1; } }P.prototype.inherited = 2;const p = new P();const arr = ['a', 'b']; arr.extra = 'x';for (const k in p) console.log('in p:', k);for (const k in arr) console.log('in arr:', k, typeof k);for (const v of arr) console.log('of arr:', v);for (const c of 'a😀b') console.log('char:', c);console.log('a😀b'.length, [...'a😀b'].length);

Output:

TEXT
in p: ownin p: inheritedin arr: 0 stringin arr: 1 stringin arr: extra stringof arr: aof arr: bchar: achar: 😀char: b4 3
for...infor...of
Loops overenumerable property names (keys)values from an iterator
Includes inherited keys?yes (inherited showed up)not applicable
Key typealways a string ("0", not 0)not applicable
Includes extra properties on an array?yes (extra)no, only the elements
Works on a plain object?yesno, it throws
Rule of thumbinspecting object keys (prefer Object.keys or Object.entries)any sequence of values

The string line shows a related detail. 'a😀b'.length is 4 because the emoji takes two 16-bit units. The string iterator walks real characters, so spread gives 3 items. If you reverse a string with .split(''), emoji break. [...str] is safer.

Spread, destructuring and friends all use it

Anything that takes "a list of values" asks for an iterator. I tried a plain object first:

JavaScript
const obj = { x: 1, y: 2 };try { for (const v of obj) console.log(v); } catch (e) { console.log(e.constructor.name + ': ' + e.message); }try { [...obj]; } catch (e) { console.log(e.constructor.name + ': ' + e.message); }try { const [a] = obj; } catch (e) { console.log(e.constructor.name + ': ' + e.message); }
TEXT
TypeError: obj is not iterableTypeError: obj is not iterableTypeError: obj is not iterable

Same error in all three places, because all three look for Symbol.iterator. Now with iterables that do work:

JavaScript
const [first, ...rest] = new Set([10, 20, 30]);console.log(first, rest);console.log(Math.max(...new Set([3, 9, 4])));
TEXT
10 [ 20, 30 ]9

The list of consumers is long: spread [...x] and f(...x), array destructuring, Array.from, new Map(x), new Set(x), Promise.all(x), Object.fromEntries(x) and yield*. Make your own object iterable once, and all of them work with it.

Part 2. Writing an iterator by hand

Here is a Range class. The console.log calls show exactly when the language calls our methods:

JavaScript
class Range {  constructor(from, to) { this.from = from; this.to = to; }  [Symbol.iterator]() {    let current = this.from;    const last = this.to;    return {      next() {        console.log('  next() called, current =', current);        return current <= last ? { value: current++, done: false } : { value: undefined, done: true };      },      return() { console.log('  return() called (early exit)'); return { done: true }; },    };  }}const r = new Range(1, 3);console.log('spread:', [...r]);for (const n of r) { console.log('got', n); if (n === 2) break; }const [a] = r;console.log('a =', a);

Output:

TEXT
  next() called, current = 1  next() called, current = 2  next() called, current = 3  next() called, current = 4spread: [ 1, 2, 3 ]  next() called, current = 1got 1  next() called, current = 2got 2  return() called (early exit)  next() called, current = 1  return() called (early exit)a = 1

Trace it:

  1. Spread calls next() until done: true. That is four calls for three values. Only then does console.log('spread:' ...) run, because the array had to be built first.
  2. for...of alternates: next(), then the body, then next(), then the body. On break at n === 2 the loop calls return(). It did not call next() a third time.
  3. Destructuring const [a] = r needs only one value. It calls next() once, then calls return(), because it stopped before the iterator said done.

Two design points:

  • Each [Symbol.iterator]() call makes a fresh iterator with its own current. That is why you can loop over r three times and it starts at 1 each time. If you returned one shared iterator, the second loop would find it already finished.
  • return() is optional. Only add it when the iterator holds something to release.

Do not rely on done: false with a missing value. The loop reads value whenever done is falsy. Always return both fields, or you will print undefined into loops.

Part 3. Generators: iterators that pause

Writing next() by hand means storing state in variables (current) and rebuilding "where was I?" every time. A generator keeps that for you. The function body is paused in the middle, with all its local variables intact.

JavaScript
function* count() {  console.log('A: start');  yield 1;  console.log('B: after first yield');  yield 2;  console.log('C: after second yield');  return 'done';}const g = count();console.log('created, nothing printed yet');console.log(g.next());console.log(g.next());console.log(g.next());console.log(g.next());console.log(g[Symbol.iterator]() === g);console.log([...count()]);

Output:

TEXT
created, nothing printed yetA: start{ value: 1, done: false }B: after first yield{ value: 2, done: false }C: after second yield{ value: 'done', done: true }{ value: undefined, done: true }trueA: startB: after first yieldC: after second yield[ 1, 2 ]

Step-by-step trace

StepWhat happens
const g = count()The body does not run. We get a generator object. Nothing is printed.
1st g.next()Body starts. Prints A: start. Reaches yield 1: pauses and returns { value: 1, done: false }.
2nd g.next()Resumes right after yield 1. Prints B. Reaches yield 2: pauses, returns { value: 2, done: false }.
3rd g.next()Resumes after yield 2. Prints C. Hits return 'done': the generator finishes, returns { value: 'done', done: true }.
4th g.next()Already finished: { value: undefined, done: true }.

Three things to notice:

  • A generator object is its own iterator: g[Symbol.iterator]() === g is true. So it works in for...of directly. But it is also one-shot. Once finished, it stays finished. To loop again, call count() again.
  • The return 'done' value shows up as value with done: true. Spread and for...of drop it: [...count()] is [1, 2]. Only a manual next() sees it.
  • Local variables survive between pauses. That is why generators beat hand-written iterators for anything with state.

Our Range becomes one line: *[Symbol.iterator]() { for (let i = this.from; i <= this.to; i++) yield i; }. The * before a computed method name makes it a generator method.

yield* runs another iterable inside a generator and passes its values out (yield* inner()). It also gives you the inner generator's return value. Use it to walk trees and chain sources.

Part 4. Lazy and infinite sequences

Because a generator computes a value only when asked, it can describe a sequence that never ends, as long as the consumer stops. Here is a chain of generators. I put a console.log in the map step to see how much work really happens:

JavaScript
function* naturals() { let n = 1; while (true) yield n++; }function* take(it, n) { let i = 0; for (const v of it) { if (i++ >= n) return; yield v; } }function* map(it, f) { for (const v of it) yield f(v); }function* filter(it, f) { for (const v of it) if (f(v)) yield v; }const evensSquared = map(filter(naturals(), n => n % 2 === 0), n => { console.log('  squaring', n); return n * n; });console.log([...take(evensSquared, 3)]);
TEXT
  squaring 2  squaring 4  squaring 6  squaring 8[ 4, 16, 36 ]

We asked for three results, but 8 was squared too. Why? My take checks the limit after it has already pulled the next value (for (const v of it) fetches first, then i++ >= n tests). So it pulled a fourth value, threw it away, and returned. It is a small off-by-one in laziness, and it shows that you can see the work with a console.log. A better take would check the count before pulling.

Each value flows through the whole pipeline one at a time. Nothing builds a full intermediate array. That is the main reason to prefer lazy pipelines for big or endless data.

Built-in iterator helpers

Node 22 has iterator helper methods (map, filter, take, drop, toArray and more) on every iterator, including generator objects. The same pipeline in one line:

JavaScript
console.log(naturals().filter(n => n % 2 === 0).map(n => n * n).take(3).toArray());
TEXT
[ 4, 16, 36 ]

These helpers worked in Node 22.22 as shown. Older browsers may lack them, so check your targets before using them in front-end code.

Lazy versus eager: a measured example

Summing five million numbers, once from a generator and once from a full array. I ran this with node --expose-gc:

JavaScript
const mb = () => Math.round(process.memoryUsage().heapUsed / 1e6) + ' MB';function* range(n) { for (let i = 0; i < n; i++) yield i; }let before = mb();let sum = 0; for (const v of range(5e6)) sum += v;console.log('generator  sum', sum, 'heap after:', mb(), '(before', before + ')');global.gc && gc();const big = Array.from({ length: 5e6 }, (_, i) => i);sum = 0; for (const v of big) sum += v;console.log('array      sum', sum, 'heap while held:', mb());
TEXT
generator  sum 12499997500000 heap after: 4 MB (before 4 MB)array      sum 12499997500000 heap while held: 45 MB

The generator never held more than one number. The array held all five million (about 41 MB more on this run). Both gave the same sum. Lazy does not mean faster; it means memory stays flat and you can stop early.

Part 5. Two-way yield: sending values in

yield is an expression. The value you pass to next(value) becomes the result of the yield the generator is paused on. That makes a generator a small two-way channel.

JavaScript
function* conversation() {  const name = yield 'What is your name?';  console.log('  inside: got', name);  const age = yield `Hi ${name}, your age?`;  console.log('  inside: got', age);  return `${name} is ${age}`;}const c = conversation();console.log(c.next('ignored'));console.log(c.next('Asha'));console.log(c.next(30));
TEXT
{ value: 'What is your name?', done: false }  inside: got Asha{ value: 'Hi Asha, your age?', done: false }  inside: got 30{ value: 'Asha is 30', done: true }

Trace:

  1. The first next('ignored') starts the body. There is no paused yield yet to receive a value, so the argument is thrown away. The body runs to the first yield and hands out the question.
  2. next('Asha') resumes. The paused yield expression evaluates to 'Asha', which goes into name. The body runs to the next yield.
  3. next(30) fills age, and the body returns the final string.

So the very first next() always only "primes" the generator.

throw() and return()

A generator also has throw(err) (raise an error at the paused yield) and return(value) (finish early, but run finally blocks).

JavaScript
function* safe() {  try { yield 1; yield 2; } catch (e) { console.log('  caught inside:', e); yield 'recovered'; } finally { console.log('  cleanup'); }}const s = safe();console.log(s.next());console.log(s.throw('boom'));console.log(s.return('bye'));console.log(s.next());const s2 = safe(); s2.next();console.log(s2.return('early'));
TEXT
{ value: 1, done: false }  caught inside: boom{ value: 'recovered', done: false }  cleanup{ value: 'bye', done: true }{ value: undefined, done: true }  cleanup{ value: 'early', done: true }
  • throw('boom') lands at yield 1, and the generator's own catch handles it. It yields 'recovered', so it is still alive.
  • return('bye') ends it at that point. The finally block ran (cleanup) and the result is { value: 'bye', done: true }.
  • This is exactly what break in a for...of does: it calls return(), so a generator that holds a resource can close it in finally. That makes generators a clean way to write "open, yield items, always close".

Two-way generators were an early way to write async code (libraries drove them with promises). async/await replaced that. Today you mostly use yield as a plain value producer.

Part 6. Async iterators and for await

Sometimes each next value is not ready yet: a page from an API, a chunk from a file. An async iterator is the same protocol, except next() returns a promise of { value, done }. You write one with async function* and loop with for await.

JavaScript
const sleep = (ms) => new Promise(r => setTimeout(r, ms));async function* ticker(n) { for (let i = 1; i <= n; i++) { await sleep(10); yield i; } }(async () => {  for await (const t of ticker(3)) console.log('tick', t);})();
TEXT
tick 1tick 2tick 3

Notes:

  • Inside async function* you can use both await and yield.
  • for await also works on a normal iterable of promises. It waits for each item in order.
  • for await may only be used inside an async function (or at the top level of an ES module, see Lesson 3).
  • Important: the body runs one value at a time. for await does not run things in parallel. If you need parallel work, collect the promises and use Promise.all (Lesson 2).

The well-known symbol for this is Symbol.asyncIterator. An object that has it works with for await.

Practical use 1: a pagination reader

A typical API returns one page and a pointer to the next. An async generator hides the paging, so the caller sees one flat stream of items:

JavaScript
const pages = { 1: { items: ['a','b'], next: 2 }, 2: { items: ['c'], next: 3 }, 3: { items: ['d','e'], next: null } };async function fetchPage(p) { console.log('  fetching page', p); await sleep(5); return pages[p]; }async function* allItems() { let p = 1; while (p) { const r = await fetchPage(p); yield* r.items; p = r.next; } }for await (const item of allItems()) { console.log('item', item); if (item === 'c') break; }console.log('stopped early; page 3 never fetched');
TEXT
  fetching page 1item aitem b  fetching page 2item cstopped early; page 3 never fetched

The caller did not know about pages. Pages are fetched only when needed, so breaking at c meant page 3 was never requested. That saves real network calls. With a real API, put the "next page" token or cursor in place of the number next.

Practical use 2: streams

Node streams are async iterable. I ran this, and for await read them chunk by chunk:

JavaScript
const { Readable } = require('node:stream');for await (const chunk of Readable.from(['x', 'y', 'z'])) console.log('chunk', chunk);const rl = require('node:readline').createInterface({ input: Readable.from(['l1\nl2\n', 'l3\n']) });for await (const line of rl) console.log('line', line);
TEXT
chunk xchunk ychunk zline l1line l2line l3

The loop waits while your body is busy, so the stream is not flooded (back-pressure). readline gave three lines even though the text came in two chunks. A break calls return(), which closes the stream. In Node 22, a fetch response body also works with for await; in browsers that varies, so check first.

Part 7. Symbols

Why symbols exist

Before symbols, object keys were only strings. If the language added a new key like iterator, it could clash with a key someone already used. A symbol is a unique key that nobody can clash with.

JavaScript
const s1 = Symbol('id'), s2 = Symbol('id');console.log(s1 === s2, s1.toString(), s1.description, typeof s1);const user = { name: 'Asha', [s1]: 42 };console.log(user, Object.keys(user), JSON.stringify(user), Object.getOwnPropertySymbols(user));for (const k in user) console.log('for-in key', k);
TEXT
false Symbol(id) id symbol{ name: 'Asha', [Symbol(id)]: 42 } [ 'name' ] {"name":"Asha"} [ Symbol(id) ]for-in key name
  • Two symbols with the same description are different. The text is only a label for debugging.
  • Symbol keys are hidden from Object.keys, for...in and JSON.stringify. They are not private (Object.getOwnPropertySymbols finds them), but they stay out of the way. Use a #private field (Lesson 1) when you need real privacy.
  • typeof gives 'symbol'.

The global registry and conversion

Symbol.for(key) looks up a symbol in a shared registry. The same key gives the same symbol, even across files:

JavaScript
console.log(Symbol.for('app.key') === Symbol.for('app.key'), Symbol.keyFor(Symbol.for('app.key')), Symbol.keyFor(s1));try { s1 + ''; } catch (e) { console.log(e.constructor.name + ': ' + e.message); }console.log(String(s1), `${s1.toString()}`);
TEXT
true app.key undefinedTypeError: Cannot convert a Symbol value to a stringSymbol(id) Symbol(id)
  • Symbol.keyFor(s1) is undefined because s1 was made with plain Symbol(), not registered.
  • Joining a symbol to a string with + throws. This is on purpose, so you do not turn a unique key into an ordinary string by accident. Use String(sym) or .toString() or .description.

Well-known symbols

The language looks for certain symbols on your objects and changes its behaviour. We have already used Symbol.iterator and Symbol.asyncIterator. Here are four more that I ran:

JavaScript
class Money {  constructor(a) { this.a = a; }  [Symbol.toPrimitive](hint) { console.log('  hint:', hint); return hint === 'string' ? `$${this.a}` : this.a; }  get [Symbol.toStringTag]() { return 'Money'; }  static [Symbol.hasInstance](x) { return typeof x === 'number'; }}const m = new Money(5);console.log(m + 1, `${m}`, m * 2, Object.prototype.toString.call(m), 3 instanceof Money);
TEXT
  hint: default  hint: string  hint: number6 $5 10 [object Money] true
SymbolWhat it controlsIn the example
Symbol.iteratorfor...of, spread, destructuringParts 1 to 3
Symbol.asyncIteratorfor await...ofPart 6
Symbol.toPrimitiveconversion to string or number. The hint is 'default', 'string' or 'number'm + 1 got default, the template got string, m * 2 got number
Symbol.toStringTagthe word in [object ...][object Money]
Symbol.hasInstancewhat instanceof means3 instanceof Money is true (a joke rule, to show the hook)
Symbol.specieswhich constructor map and filter use for the resultsee below
Symbol.isConcatSpreadablewhether concat flattens an objectsee below

Symbol.species and Symbol.isConcatSpreadable also exist: in my run, map on an Array subclass with species set to Array returned a plain Array, and an array-like object flagged spreadable was flattened by concat. Symbol.dispose and Symbol.asyncDispose exist in this Node too (for the using keyword, which I did not test).

Practical symbol uses

  • Hidden metadata keys on objects you do not own, without a name clash (a library can tag a DOM node or an options object).
  • Constants that must be unique, like an action type or a "no value yet" sentinel that can never equal a user string.
  • Hooks into the language, as shown above: make your class iterable, printable, or convertible.

Try it

  1. Write an object countdown(3) that is iterable and prints 3, 2, 1 with for...of, using a hand-written iterator (no generator).
  2. Rewrite it with a generator. How many lines does it save?
  3. What does this print? function* g(){ const x = yield 1; console.log(x); } const it = g(); it.next('a'); it.next('b');
  4. Write a generator chunk(iterable, size) that yields arrays of size items. Test it with an infinite source and .take(2).

Answers

JavaScript
const countdown = (n) => ({  [Symbol.iterator]() { let c = n; return { next: () => c > 0 ? { value: c--, done: false } : { value: undefined, done: true } }; },});
  1. const countdown = function* (n) { while (n > 0) yield n--; }; The generator drops the result objects and the saved state. A generator object is itself iterable, so no Symbol.iterator is needed.
  2. It prints b. The first next('a') only starts the generator, so 'a' is lost. The second call's 'b' becomes the value of yield 1.
  3. function* chunk(it, size) { let buf = []; for (const v of it) { buf.push(v); if (buf.length === size) { yield buf; buf = []; } } if (buf.length) yield buf; } Then chunk(naturals(), 3).take(2).toArray() gives [[1,2,3],[4,5,6]]. The last if handles a final partial chunk.

Interview questions

What is the difference between an iterable and an iterator? (Must know)

An iterable is an object with a method at Symbol.iterator. Calling that method returns an iterator. An iterator is an object with a next() method that returns { value, done }. Arrays, strings, Maps and Sets are iterable. A generator object is both: it is an iterator and its Symbol.iterator returns itself. I tested that g[Symbol.iterator]() === g is true.

What does for...of do under the hood? (Must know)

It calls Symbol.iterator once to get an iterator, then calls next() repeatedly until done is true, putting each value into the loop variable. If I leave early with break, return or a throw, it calls the iterator's return() method so the iterator can clean up. I showed this with a hand-written Range that logged each call. Destructuring const [a] = r also called return() after one next().

What is the difference between for...in and for...of? (Trap)

for...in loops over enumerable property names, as strings, and it includes inherited properties. In my run it printed an inherited key and an extra property that I had added to an array. for...of loops over values produced by an iterator, and it skips extra properties. for...of throws on a plain object, because it is not iterable. I use Object.entries for objects and for...of for sequences.

Explain how a generator works. What does yield do? (Must know)

Calling a generator function does not run its body. It returns a generator object. Each next() runs the body until the next yield, where it pauses and returns { value, done: false }. The local variables stay as they are. The next next() resumes right after that yield. When the function returns, I get { value: <return value>, done: true }. In my trace, the console.log lines in the body printed only when I called next(), not when I created the generator.

How can you pass data into a running generator? Why is the first next() argument ignored? (Senior signal)

The argument of next(x) becomes the result of the yield expression where the generator is paused. On the first call the generator has not reached any yield yet, so there is nothing to receive the value, and it is dropped. I proved it with a small conversation generator: next('ignored') was lost, next('Asha') filled name. The generator also has throw(), which raises an error at the paused yield, and return(), which finishes it and runs finally.

When would you use a generator instead of building an array? (Must know)

When the data is large, infinite, or expensive and the caller may not need all of it. A generator produces one value at a time, so memory stays flat. In my measurement, summing five million numbers kept the heap at about 4 MB with a generator, and about 45 MB with an array held in memory (these numbers are from my machine). It also lets the caller stop early. The cost is that a generator is one-shot, so I cannot loop it twice without calling the function again.

What is a lazy pipeline, and how can you see the laziness? (Senior signal)

A chain like naturals() -> filter -> map -> take where each value travels through all the steps before the next value is pulled. I put a console.log in the map step and saw squaring 2, 4, 6, 8 come out one at a time with no intermediate array. I also saw that my take pulled one extra value, because it checked the limit after fetching. That is a real off-by-one bug in lazy code: check the count before you pull.

What is an async iterator, and when is for await better than await Promise.all? (Senior signal)

An async iterator's next() returns a promise, and for await waits for each value in order. It is the right tool when values arrive over time (stream chunks, pages, events) or when the dataset is too large to hold. Promise.all is for a known set of independent promises that I want to run in parallel. for await is sequential: the loop body runs for one item at a time. For pagination I use an async generator that fetches the next page only when asked, so a break stops the fetching. I showed that page 3 was never requested.

What is a Symbol and why was it added? (Must know)

A symbol is a primitive whose values are always unique, even with the same description (Symbol('id') === Symbol('id') is false). It was added so that new language features, like Symbol.iterator, could use property keys that cannot clash with existing string keys. Symbol keys do not appear in Object.keys, for...in or JSON.stringify, but they are not private: Object.getOwnPropertySymbols finds them. For real privacy I use #private fields.

Name some well-known symbols and what they let you customise. (Senior signal)

Symbol.iterator for for...of and spread, Symbol.asyncIterator for for await, Symbol.toPrimitive for how an object turns into a string or a number (it receives the hint default, string or number), Symbol.toStringTag for [object X], Symbol.hasInstance for instanceof, and Symbol.species for what constructor map and filter use on subclasses. I ran each in a small class and saw the hooks fire. Follow-up: the hint for m + 1 was default, for a template string it was string, and for m * 2 it was number.

Key takeaways

  • An iterable has [Symbol.iterator](). An iterator has next() returning { value, done }. Spread, destructuring, for...of, Array.from, Map, Set and Promise.all all use this one protocol.
  • for...in gives keys (strings, including inherited ones). for...of gives values from an iterator. Plain objects are not iterable.
  • Leaving a loop early calls return(). Use finally in a generator to clean up.
  • A generator pauses at each yield and keeps its locals. Calling it runs nothing until the first next(). It is one-shot.
  • Generators give lazy, even infinite, sequences with flat memory. Node 22 also has iterator helpers (map, filter, take, toArray).
  • next(x) sends x in as the result of the paused yield. The first next() argument is dropped.
  • async function* and for await handle values that arrive over time: pages, streams, lines. The loop is sequential, and break stops further fetching.
  • A symbol is a unique key. Well-known symbols let your objects plug into the language itself.

What's next

Lesson 5: The DOM and Events moves from the language to the browser. You will see how the page is a tree of objects, and how a click travels through it.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series