JavaScript · lesson 22 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Also part of: TypeScript
Iterators and Generators in JavaScript
Learn the iterator protocol behind for...of and spread, make your own objects iterable, and use generators for lazy pipelines and async iteration.
By SkillByExample · Updated
for...of, the spread operator ([...x]), and array destructuring all work on arrays, strings, Maps, and Sets — and on any object you write yourself — because all of them are built on one shared protocol, not four separate features. Generators are the language’s shortcut for implementing that protocol without writing the bookkeeping by hand. Every example runs with node (Node v24.18.1).
The Iterator Protocol
An iterator is any object with a next() method that returns { value, done }. Arrays have one, reachable through the well-known symbol Symbol.iterator.
// iterator-protocol.js
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());
$ node iterator-protocol.js
{ value: 'a', done: false }
{ value: 'b', done: false }
{ value: undefined, done: true }
{ value: undefined, done: true }
Two real values, then done: true once the array is exhausted — and calling next() again doesn’t throw or restart, it just keeps reporting done: true. for...of, spread, and destructuring never see this object directly; they call arr[Symbol.iterator]() once to get it, then call .next() in a loop until done is true. Learn that one shape and you understand what all three language features are doing underneath.
Not every object has that method, and for...of doesn’t silently do nothing when it’s missing:
// not-iterable.js
for (const x of {}) {
console.log(x);
}
$ node not-iterable.js
C:\tmp\jsiter\not-iterable.js:2
for (const x of {}) {
^
TypeError: {} is not iterable
at Object.<anonymous> (C:\tmp\jsiter\not-iterable.js:2:17)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
A plain {} has no [Symbol.iterator] method, so for...of throws immediately, before the loop body ever runs — a TypeError, not an empty loop. The next section is what fixes that.
Making Your Own Object Iterable
Any object that has a [Symbol.iterator] method returning a conforming iterator works with for...of, spread, and destructuring — nothing else is required.
// custom-iterable.js
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) return { value: current++, done: false };
return { value: undefined, done: true };
},
};
}
}
const range = new Range(1, 5);
console.log([...range]);
console.log(Math.max(...range));
for (const n of range) {
console.log("n =", n);
}
const [first, second] = range;
console.log(first, second);
$ node custom-iterable.js
[ 1, 2, 3, 4, 5 ]
5
n = 1
n = 2
n = 3
n = 4
n = 5
1 2
Four completely different-looking consumers — spread, Math.max(...), for...of, and destructuring — all work on Range with no special-casing, because [Symbol.iterator] is the only thing any of them actually calls. Each one gets a fresh iterator (a new current closure), which is why range can be spread, looped, and destructured repeatedly without exhausting it.
Generators: Writing an Iterator Without the Bookkeeping
A generator function (function*) builds a conforming iterator for you. yield pauses execution and hands back one value; the next next() call resumes exactly where it left off.
// generator-range.js
function* range(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
const gen = range(1, 3);
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
console.log([...range(1, 5)]);
$ node generator-range.js
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
[ 1, 2, 3, 4, 5 ]
Compare this to custom-iterable.js: the { value, done } objects are identical in shape, but there’s no manual current variable or done check — the for loop’s own position is the state, suspended between yields. Calling range(1, 5) doesn’t run the function; it returns a generator object, which is already iterable (it has its own [Symbol.iterator] that returns itself), which is why spread works on it directly.
Lazy, Infinite Pipelines
Because a generator only runs up to the next yield, chaining generators builds a pipeline where nothing is computed until a consumer asks for it — even over an infinite source.
// lazy-pipeline.js
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* map(iterable, fn) {
for (const x of iterable) yield fn(x);
}
function* filter(iterable, predicate) {
for (const x of iterable) if (predicate(x)) yield x;
}
function take(iterable, count) {
const result = [];
const it = iterable[Symbol.iterator]();
for (let i = 0; i < count; i++) {
const { value, done } = it.next();
if (done) break;
result.push(value);
}
return result;
}
let computed = 0;
const squares = map(naturals(), (n) => { computed++; return n * n; });
const evenSquares = filter(squares, (n) => n % 2 === 0);
console.log(take(evenSquares, 5));
console.log("values pulled from naturals():", computed);
$ node lazy-pipeline.js
[ 4, 16, 36, 64, 100 ]
values pulled from naturals(): 10
naturals() never stops on its own — while (true) — yet the whole program finishes, because take only pulls as many values through evenSquares → squares → naturals() as it needs, one at a time. computed is 10, not some larger number and not infinity: finding 5 even squares takes exactly 10 natural numbers (every other one squares to an even number). None of map’s or filter’s bodies ran ahead of what take asked for.
Built-in Iterator Helpers
The map/filter/take functions above are hand-written for a reason: until recently, the language had no built-in equivalent. As of ES2025, filter, map, take, drop, and toArray (among others) are methods on every iterator directly — not just on arrays — and Iterator.from() adds them to an iterator that doesn’t already have them.
// iterator-helpers.js
function* naturals() {
let n = 1;
while (true) yield n++;
}
let filterCalls = 0;
const chain = naturals()
.filter((n) => {
filterCalls++;
return n % 2 === 1;
})
.map((n) => n * n)
.take(4);
console.log("chain built, filterCalls so far:", filterCalls);
console.log(chain.toArray());
console.log("filterCalls after toArray():", filterCalls);
console.log(new Set(["x", "y", "z"]).values().drop(1).toArray());
function makeCounter(limit) {
let current = 1;
return {
next() {
if (current > limit) return { value: undefined, done: true };
return { value: current++, done: false };
},
};
}
console.log(typeof makeCounter(5).filter);
console.log(Iterator.from(makeCounter(5)).filter((n) => n % 2 === 0).toArray());
$ node iterator-helpers.js
chain built, filterCalls so far: 0
[ 1, 9, 25, 49 ]
filterCalls after toArray(): 7
[ 'y', 'z' ]
undefined
[ 2, 4 ]
filterCalls is still 0 right after chain is built — .filter().map().take() returns a new iterator, it doesn’t run anything yet. It only reaches 7 once .toArray() actually pulls values, which is the same laziness as the hand-written pipeline above, now built into the chain itself rather than into functions we wrote. new Set(...).values() already has .drop() on it with no conversion step, because %IteratorPrototype% sits underneath every built-in iterator — generators, Map/Set iterators, and arrays’ own [Symbol.iterator]() alike. makeCounter’s hand-written iterator is the exception: typeof makeCounter(5).filter is undefined, because a plain { next() {...} } object (like the one in custom-iterable.js earlier) doesn’t inherit from %IteratorPrototype% on its own — Iterator.from() wraps it in an object that does, which is what makes .filter() available on it at all.
Early return and finally Cleanup
Leaving a for...of loop before a generator is exhausted — break, an early return, or a thrown error — calls the generator’s return() method automatically, which runs any finally block wrapped around the paused yield.
// generator-cleanup.js
function* readLines() {
try {
console.log("opening resource");
yield "line 1";
yield "line 2";
yield "line 3";
} finally {
console.log("closing resource");
}
}
for (const line of readLines()) {
console.log(line);
if (line === "line 2") break;
}
console.log("---");
const gen = readLines();
console.log(gen.next());
console.log(gen.return("stopped early"));
console.log(gen.next());
$ node generator-cleanup.js
opening resource
line 1
line 2
closing resource
---
opening resource
{ value: 'line 1', done: false }
closing resource
{ value: 'stopped early', done: true }
{ value: undefined, done: true }
break after "line 2" never lets the generator reach "line 3", yet "closing resource" still prints — the implicit return() call makes the suspended yield behave as if a return statement sat right there, running the finally. Calling gen.return() by hand does the same thing: it reports done: true immediately with the value you passed in, and any further next() call just keeps reporting done: true, matching the exhausted-iterator behavior from the first example.
yield*: Delegating to Another Generator
yield* runs another iterable to completion, re-yielding each of its values as if they were yielded directly — and the expression itself evaluates to that generator’s return value.
// yield-star.js
function* inner() {
yield "a";
yield "b";
return "inner done";
}
function* outer() {
yield 1;
const result = yield* inner();
console.log("inner returned:", result);
yield 2;
}
console.log([...outer()]);
$ node yield-star.js
inner returned: inner done
[ 1, 'a', 'b', 2 ]
The flattened sequence is 1, 'a', 'b', 2 — outer never sees inner’s values as a nested array, they’re spliced straight into its own output. inner’s return "inner done" doesn’t appear in that sequence at all (a return value is never yielded); it becomes the value of the yield* inner() expression itself, which is what result captures.
Async Generators and for await…of
An async function* combines both: await for asynchronous work, yield for producing values one at a time. for await...of consumes it, awaiting each value before the loop body runs.
// async-generator.mjs
async function* fetchPages(totalPages) {
for (let page = 1; page <= totalPages; page++) {
await new Promise((r) => setTimeout(r, 20));
yield `page ${page} of ${totalPages}`;
}
}
for await (const page of fetchPages(3)) {
console.log(page);
}
$ node async-generator.mjs
page 1 of 3
page 2 of 3
page 3 of 3
Each page is fetched (with a short delay standing in for real network latency) only when the loop asks for it — the same laziness as the synchronous pipeline, now with asynchronous steps in between. fetchPages could be a real paginated API call with no change to how it’s consumed; for await...of works the same way over any object whose Symbol.asyncIterator method returns an async iterator — one whose next() returns a Promise resolving to { value, done }, which for await...of awaits on every step.
Key Takeaways
- An iterator is any object with
next()returning{ value, done }; an iterable is any object with a[Symbol.iterator]method returning one. for...of, spread, and destructuring all work by calling[Symbol.iterator]()once and thennext()in a loop — implement that method and all three work for free.- A generator (
function*) builds a conforming iterator from ordinary control flow;yieldpauses and resumes exactly where execution stopped. - Generator pipelines are lazy: nothing downstream computes a value until something upstream is asked for one, which is how an infinite source can still terminate.
- ES2025 adds
filter/map/take/drop/toArrayas methods on every iterator’s shared prototype, not just on arrays;Iterator.from()adds them to a hand-written iterator that doesn’t have them yet. The chain stays just as lazy as a hand-written pipeline — nothing runs until something like.toArray()pulls values. - Leaving a
for...ofloop early calls the generator’sreturn(), running anyfinallyaround the pausedyield;yield*delegates to another generator and evaluates to its return value;for await...ofis the async-generator equivalent.
Practice
1. Rewrite the Range class's [Symbol.iterator] as a generator method instead of a hand-written iterator object, and prove it's re-iterable.
// p1.js
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) {
yield i;
}
}
}
const range = new Range(1, 5);
console.log([...range]);
console.log([...range]); // re-iterable: a fresh generator each time
$ node p1.js
[ 1, 2, 3, 4, 5 ]
[ 1, 2, 3, 4, 5 ]
*[Symbol.iterator]() is a generator method — calling it returns a new generator object every time, exactly like the manual version returned a new closure-based iterator every time. Both spreads print the same values because neither call affects the other.
2. Using the lazy-pipeline building blocks, find the first 3 multiples of 7 from naturals() and count exactly how many naturals were pulled.
// p2.js
function* naturals() {
let n = 1;
while (true) yield n++;
}
function* filter(iterable, predicate) {
for (const x of iterable) if (predicate(x)) yield x;
}
function take(iterable, count) {
const result = [];
const it = iterable[Symbol.iterator]();
for (let i = 0; i < count; i++) {
const { value, done } = it.next();
if (done) break;
result.push(value);
}
return result;
}
let pulled = 0;
const source = (function* () {
for (const n of naturals()) {
pulled++;
yield n;
}
})();
const multiplesOf7 = filter(source, (n) => n % 7 === 0);
console.log(take(multiplesOf7, 3));
console.log("naturals pulled:", pulled);
$ node p2.js
[ 7, 14, 21 ]
naturals pulled: 21
Finding the 3rd multiple of 7 means checking every natural number up to 21 — the pipeline pulls exactly 21, not some larger buffered batch and not all of them.
3. A generator's third method, throw(), injects an error at the paused yield. Write a generator that catches it internally and keeps going.
// p3.js
function* resilientWorker() {
try {
yield "step 1";
yield "step 2";
} catch (err) {
console.log("caught inside generator:", err.message);
yield "recovered";
}
yield "step 3";
}
const gen = resilientWorker();
console.log(gen.next());
console.log(gen.throw(new Error("injected failure")));
console.log(gen.next());
$ node p3.js
{ value: 'step 1', done: false }
caught inside generator: injected failure
{ value: 'recovered', done: false }
{ value: 'step 3', done: false }
gen.throw(err) makes the error surface exactly where the generator is paused, as if that yield had thrown it — the surrounding try/catch handles it like any synchronous exception, and execution continues on the next line afterward.
4. Break out of a for await...of loop early over an async generator that has a finally block, and confirm the cleanup still runs.
// p4.mjs
async function* countdown(from) {
try {
for (let n = from; n > 0; n--) {
await new Promise((r) => setTimeout(r, 10));
yield n;
}
} finally {
console.log("countdown cleanup ran");
}
}
for await (const n of countdown(5)) {
console.log(n);
if (n === 3) break;
}
$ node p4.mjs
5
4
3
countdown cleanup ran
break after 3 never lets countdown count down to 1, but the implicit return() call that for await...of makes on early exit still runs the finally — the async generator cleans up exactly the way the synchronous one did.
5. Redo Practice 2 — the first 3 multiples of 7 from naturals(), with a count of how many naturals were pulled — using the built-in iterator helpers instead of the hand-written map/filter/take functions.
// p5.js
function* naturals() {
let n = 1;
while (true) yield n++;
}
let pulled = 0;
const result = naturals()
.filter((n) => {
pulled++;
return n % 7 === 0;
})
.take(3)
.toArray();
console.log(result);
console.log("naturals pulled:", pulled);
$ node p5.js
[ 7, 14, 21 ]
naturals pulled: 21
Same answer and the same count as Practice 2’s hand-written version — 21, not fewer — because .filter() here still has to run its predicate on every natural number up to 21 one at a time, and .take(3) only stops pulling once the third match has come through. The built-in helpers change who writes the plumbing, not how lazily it runs.