JavaScript Iterators & Generators
Every for…of loop, every spread ..., every destructure of an array — they all run on the same hidden contract: the iterator protocol. Generators (function* and yield) are the easy way to author iterators. Together, they unlock lazy sequences, custom iteration, and (with async function*) clean streaming code. This chapter covers all of it.
The Iterator Protocol
An iterator is any object with a next() method that returns { value, done }:
const it = {
i: 0,
next() {
return this.i < 3
? { value: this.i++, done: false }
: { value: undefined, done: true };
},
};
it.next(); // Output: { value: 0, done: false }
it.next(); // Output: { value: 1, done: false }
it.next(); // Output: { value: 2, done: false }
it.next(); // Output: { value: undefined, done: true }
An iterable is any object with a [Symbol.iterator]() method that returns an iterator. Arrays, strings, Map, Set, arguments, NodeList, and many DOM collections are all iterables.
const list = [10, 20, 30];
const it = list[Symbol.iterator]();
it.next(); // { value: 10, done: false }
Anywhere for…of Works, So Do These
for (const x of iterable)[...iterable]Array.from(iterable)new Set(iterable),new Map(iterable)- Destructuring
[a, b] = iterable Promise.all(iterable)
Make a class iterable, and all of the above light up for free.
Build Your Own Iterable
class Range {
constructor(start, end, step = 1) {
this.start = start; this.end = end; this.step = step;
}
[Symbol.iterator]() {
let n = this.start;
const { end, step } = this;
return {
next: () => n < end
? { value: n += 0, done: false, ...(n += step, {}) } // ugly!
: { value: undefined, done: true },
};
}
}
…and that’s exactly why generators exist.
Generators — function* + yield
A generator function returns an iterator automatically. yield pauses; next() resumes.
function* range(start, end, step = 1) {
for (let n = start; n < end; n += step) yield n;
}
for (const n of range(1, 6)) console.log(n);
// 1 2 3 4 5
[...range(0, 4)]; // Output: [0, 1, 2, 3]
That’s it — yield does all the iterator bookkeeping for you.
Make a Class Iterable with *[Symbol.iterator]
class Stack {
#items = [];
push(x) { this.#items.push(x); }
*[Symbol.iterator]() { // generator method
for (let i = this.#items.length - 1; i >= 0; i--) yield this.#items[i];
}
}
const s = new Stack();
s.push(1); s.push(2); s.push(3);
[...s]; // Output: [3, 2, 1]
yield* — Delegate to Another Iterable
function* a() { yield 1; yield 2; }
function* b() {
yield 0;
yield* a(); // delegate
yield 3;
}
[...b()]; // Output: [0, 1, 2, 3]
yield* works with any iterable — arrays, sets, other generators, your custom classes.
Lazy Sequences (Including Infinite Ones)
Generators only produce values on demand — perfect for infinite or huge sequences:
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* take(iter, n) {
for (const v of iter) {
if (n-- <= 0) return;
yield v;
}
}
[...take(naturals(), 5)]; // Output: [1, 2, 3, 4, 5]
Nothing is stored — values stream through.
A Tiny Pipeline
function* map(iter, fn) { for (const v of iter) yield fn(v); }
function* filter(iter, pred){ for (const v of iter) if (pred(v)) yield v; }
const firstFiveSquaredEvens = take(
map(filter(naturals(), n => n % 2 === 0), n => n * n),
5
);
[...firstFiveSquaredEvens]; // Output: [4, 16, 36, 64, 100]
Pure laziness — naturals() is never iterated past element 10.
Passing Values Back In — next(value)
Generators are two-way: yield evaluates to whatever the caller passes into next().
function* echo() {
while (true) {
const x = yield;
console.log("got", x);
}
}
const g = echo();
g.next(); // start it — runs until first yield
g.next("hi"); // Output: got hi
g.next("world"); // Output: got world
This is the foundation of coroutines and was the trick that early async/await libraries used to make sync-looking async code.
Throwing Into and Returning Out
function* g() {
try {
yield 1;
yield 2;
} catch (e) {
console.log("caught", e.message);
}
}
const it = g();
it.next(); // { value: 1 }
it.throw(new Error("x")); // logs "caught x", returns { done: true }
it.return(value) early-terminates and runs finally blocks:
function* g() {
try { yield 1; yield 2; } finally { console.log("cleanup"); }
}
const it = g();
it.next(); // 1
it.return(99); // logs "cleanup"; returns { value: 99, done: true }
for…of automatically calls return() when you break early — generators clean up reliably.
Async Iterators — for await…of
Symbol.asyncIterator + async function* describe streams of promises:
async function* lines(stream) {
let buffer = "";
const decoder = new TextDecoder();
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true });
let i;
while ((i = buffer.indexOf("\n")) >= 0) {
yield buffer.slice(0, i);
buffer = buffer.slice(i + 1);
}
}
if (buffer) yield buffer;
}
const res = await fetch("/big.log");
for await (const line of lines(res.body)) {
console.log(line);
}
Async generators are perfect for paginated APIs, streaming downloads, server-sent events, and Node streams.
async function* pages(api) {
let url = api;
while (url) {
const { results, next } = await fetch(url).then(r => r.json());
yield* results;
url = next;
}
}
for await (const item of pages("/api/items")) handle(item);
Iterator Helpers (ES2025)
Modern engines add helper methods directly on iterators — so you can chain without your own map/filter/take:
const first5SquaredEvens = naturals()
.filter((n) => n % 2 === 0)
.map((n) => n * n)
.take(5)
.toArray();
// Output: [4, 16, 36, 64, 100]
Available helpers (when supported): map, filter, take, drop, flatMap, reduce, forEach, some, every, find, toArray. They keep things lazy until a terminal operation (like toArray or forEach).
Tip: Check support in your target runtime — these helpers are new. Where unavailable, the manual map/filter/take generators above are a few lines each.
Real-World Patterns
| Use case | Tool |
|---|---|
| Walk a tree (DOM, AST) | recursive generator with yield* |
| Pagination | async generator that fetches next page |
| Throttled / batched processing | generator + scheduler |
| Lazy parsing of a stream | async generator yielding tokens |
| Custom collections (Stack, Graph, LinkedList) | *[Symbol.iterator] |
| Combining sequences | yield* delegation |
Tree Walk Example
function* walk(node) {
yield node;
for (const c of node.children) yield* walk(c);
}
for (const n of walk(document.body)) {
if (n.tagName === "A") console.log(n.href);
}
Common Mistakes
// ❌ Re-iterating a "used" iterator
const it = [1, 2, 3][Symbol.iterator]();
[...it]; // [1, 2, 3]
[...it]; // [] — already exhausted
// ✅ Iterate the iterable, not the iterator
const arr = [1, 2, 3];
[...arr]; [...arr];
// ❌ Forgetting * on the method
class A { [Symbol.iterator]() { yield 1; } } // SyntaxError
// ❌ Mixing `next` value semantics
function* g() { const x = yield; yield x + 1; }
const it = g();
it.next(10); // { value: undefined } — value to FIRST next is ignored
it.next(10); // { value: 11 }
// ❌ Treating an async generator like a sync one
const ag = (async function*() { yield 1; })();
for (const v of ag) {} // TypeError — need for await
Best Practices Checklist
- ✅ Use generators to author custom iteration — almost always simpler than manual
{next}objects - ✅ Use
*[Symbol.iterator]()to make your classes work withfor…ofand spread - ✅ Use
yield*to compose generators (and any iterables) - ✅ Use async generators +
for await…offor paginated APIs and streams - ✅ Use iterator helpers (ES2025) where available — they’re lazy by design
- ✅ Let
for…ofclean up — it callsreturn()on earlybreak - ❌ Don’t reuse an exhausted iterator
- ❌ Don’t return huge arrays from a function when a generator can stream them
- ❌ Don’t put expensive work in the generator body unless it must run per-pull
What’s Next?
You now know the full mainstream JavaScript surface — values, control flow, functions, classes, modules, async, regex, collections, iteration. The final chapter pulls everything together: JavaScript best practices — the conventions, patterns, and trade-offs that turn working code into good code.