061: Iterators, Iterables, and Generators
Outcomes
By the end of this lesson, you can:
- explain the iterable and iterator protocols;
- identify values that work with
for...of; - use
Symbol.iterator; - create custom iterables;
- write generator functions with
function*; - use
yieldto produce lazy sequences; - understand where generators improve clarity and where they add unnecessary complexity.
Iterable versus Iterator
When JavaScript says that a value can be iterated, it is describing a protocol rather than a particular data structure. An iterable provides a way to create an iterator. An iterator produces a sequence of { value, done } results, one step at a time.
Arrays, strings, Maps, and Sets are iterable. That is why a for...of loop can consume them without knowing how each type stores its data:
for (const character of "JS") {
console.log(character);
}
Under the hood, the loop obtains an iterator and repeatedly calls next(). It stops when the result has done: true. An iterable and its iterator are not necessarily the same object. For example, an array is an iterable, while the object returned by its Symbol.iterator method tracks one traversal through that array.
Inspecting the Iterator
You can obtain an array's iterator explicitly and inspect the protocol results:
const values = [10, 20];
const iterator = values[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
Typical result:
{ value: 10, done: false }
{ value: 20, done: false }
{ value: undefined, done: true }
The first two calls return values from the array. The third reports that the iterator is exhausted. An exhausted iterator does not restart automatically; further calls remain done. If you need a new traversal, ask the iterable for another iterator.
This shape is also useful while debugging custom iteration. If next() returns the wrong object, or never eventually returns done: true, a for...of loop or spread operation can fail or behave unexpectedly.
Custom Iterable
An object can work with for...of and spread when it exposes a method at Symbol.iterator. That method must return an iterator with a next() method. This example implements an inclusive range directly with the protocol:
const range = {
start: 1,
end: 3,
[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,
};
},
};
},
};
console.log([...range]); // [1, 2, 3]
current belongs to the iterator created by [Symbol.iterator](), so it preserves the position for that traversal. Each call either returns the next number or signals completion. The spread expression keeps requesting values until completion and collects them into an array.
This is valid but verbose. Manual protocol code is appropriate when you need precise control, but it also means managing more state yourself. Generators provide the same iterator behavior with less bookkeeping.
Generator Functions
Generators make iterator creation much easier. The function* syntax defines a generator function, and each yield supplies the next value:
function* range(start, end) {
for (let value = start; value <= end; value += 1) {
yield value;
}
}
console.log([...range(1, 3)]);
Calling a generator does not immediately run the entire body. It returns a generator object. Execution starts when the consumer calls next() and pauses again at the next yield.
const sequence = range(1, 3);
console.log(sequence.next());
console.log(sequence.next());
The first call runs through the function until it reaches the first yield. The second resumes just after that yield. The generator preserves local variables, including value, between calls. The generator object is itself an iterator and can also be consumed by for...of or spread.
Lazy Production
Generators are useful when values can be produced on demand. They do not need to build an entire sequence before returning its first value:
function* ids(prefix = "ORD") {
let number = 1;
while (true) {
yield `${prefix}-${number}`;
number += 1;
}
}
const orderIds = ids();
console.log(orderIds.next().value);
console.log(orderIds.next().value);
console.log(orderIds.next().value);
The infinite loop is safe only because execution pauses at each yield and the consumer controls how far it advances. Three calls to next() produce three IDs; the generator does not try to calculate an infinite array.
Lazy production can avoid unnecessary work and memory use for large sequences. It does not make every implementation clearer, though. A small, ordinary collection is often easier to express with an array, map, filter, or a simple loop. If you convert a huge lazy sequence to an array, you also lose the memory benefit and may consume more data than intended.
yield*
Use yield* to delegate to another iterable:
function* combined() {
yield* [1, 2];
yield* [3, 4];
}
console.log([...combined()]);
The surrounding generator exposes one sequence containing all four values. yield* delegates to any iterable, not just another generator, so an array, string, Set, or custom iterable can be used. This is useful when a larger sequence is naturally composed from smaller sequences.
Passing Values Back into a Generator
Generators are two-way at a low level. A value passed to a later next(value) call becomes the result of the suspended yield expression:
function* conversation() {
const name = yield "What is your name?";
yield `Hello ${name}`;
}
const flow = conversation();
console.log(flow.next().value);
console.log(flow.next("Maya").value);
The first next() starts the generator and returns the question. The generator is paused at that yield. The second call resumes it, so the suspended expression evaluates to "Maya", and the next yielded value is Hello Maya.
This feature exists, but ordinary application workflows are often clearer with normal functions or async functions. Generator communication is worth understanding because it is part of the protocol, not because every data flow should use it.
Iterables and Spread
Spread consumes iterables:
const letters = [..."JavaScript"];
The string supplies an iterator, and spread consumes it one character at a time. Many constructors consume iterables too:
const unique = new Set(["a", "b", "a"]);
const copied = [...unique];
The Set removes the duplicate while being created, and spread then creates an array from the Set's iterable values. Understanding iterables explains why these language features compose naturally instead of requiring a separate conversion rule for every collection type.
Async Iteration Preview
An async iterable can produce values over time. An async generator uses await while loading each value and yield to expose it to the consumer:
async function* pages(loadPage) {
let page = 1;
while (true) {
const result = await loadPage(page);
if (result.items.length === 0) {
return;
}
yield result.items;
page += 1;
}
}
Consumption:
for await (const items of pages(loadPage)) {
console.log(items);
}
Each loop iteration waits for the next result. Treat this as an advanced bridge to asynchronous streams rather than a required pattern for every API call. A normal one-shot request is usually clearer as a normal async function; async iteration earns its complexity when the domain naturally consists of a sequence of values arriving over time.
Worked Example: Paginated Batch Generator
This generator turns an array into fixed-size batches. It validates the size before doing any work, then yields each slice lazily:
function* batches(items, size) {
if (!Number.isInteger(size) || size <= 0) {
throw new Error("size must be a positive integer");
}
for (let index = 0; index < items.length; index += size) {
yield items.slice(index, index + size);
}
}
for (const batch of batches([1, 2, 3, 4, 5], 2)) {
console.log(batch);
}
Output:
[1, 2]
[3, 4]
[5]
The final batch is allowed to be smaller than size. The loop advances by the requested batch size, and slice stops at the end of the input rather than requiring the final batch to be padded. Invalid sizes throw immediately, which is easier to diagnose than silently producing no values or entering a loop that cannot advance.
Advanced Notes: Iterator Cleanup and Async Iterables
A for...of loop can request cleanup from an iterator when iteration ends early. Generators support this through their return() behavior and finally:
function* values() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("cleanup");
}
}
for (const value of values()) {
console.log(value);
if (value === 2) {
break;
}
}
When the loop breaks, the generator is closed and the finally block runs. This matters when an iterator owns a resource or lifecycle, such as a subscription or another operation that needs explicit cleanup. The cleanup code should be written so it runs both on normal completion and on early termination.
Generator delegation
function* menu() {
yield "Home";
yield* ["Orders", "Reports"];
yield "Settings";
}
yield* delegates to any iterable, not just another generator. Here, the generated menu sequence is Home, Orders, Reports, and Settings.
Async iterable example
A paginated API can expose data one page at a time:
async function* fetchPages(fetchPage) {
let page = 1;
while (true) {
const result = await fetchPage(page);
if (result.items.length === 0) {
return;
}
yield result.items;
page += 1;
}
}
Consumer:
for await (const items of fetchPages(fetchPage)) {
render(items);
}
This can model streams naturally, but ordinary one-shot requests should stay ordinary. Advanced syntax is useful when it clarifies the domain, not when it merely demonstrates language knowledge. In a real paginated integration, the loader would also need an appropriate stopping condition and error handling for the API's contract.
Mistakes and Debugging
- expecting a generator call to execute the whole body immediately;
- forgetting that an iterator can be exhausted;
- reusing an exhausted generator instead of creating a new one;
- using generators when
map,filter, or a simple loop is clearer; - converting a huge lazy sequence to an array and losing the memory benefit.
When debugging, first identify whether you are holding an iterable or an already-used iterator. Inspect a few next() results and check both value and done. If no values are produced, verify that the generator reaches a yield and that a loop condition can advance. If memory usage is unexpectedly high, look for spread or an array constructor consuming the entire sequence.
Best Practices
- Prefer ordinary arrays and loops for ordinary collections.
- Use generators when lazy sequencing improves the problem model.
- Keep generator state simple.
- Use
for...ofinstead of manualnext()calls for normal consumption. - Learn the protocol because many JavaScript features build on it.
Manual next() calls are valuable for learning the protocol and for specialized control, but they make normal application code more stateful. Let the consuming construct handle completion when a for...of loop expresses the intent clearly.
Exercises
Core
Manually call .next() on an array iterator. Confirm which calls return done: false, which call reports done: true, and what happens if you call next() again after exhaustion.
Practice
Write function* countdown(from). It should yield values in descending order and stop after yielding 0. Decide how the function should behave for a starting value below 0, and test that behavior rather than leaving it implicit.
Professional Extension
Write function* chunks(array, size) and unit-test invalid sizes and the final partial chunk. The generator should reject non-positive or non-integer sizes, yield each contiguous chunk once, and preserve a final chunk when the array length is not evenly divisible by size.
Recap
Iterables define how values can be consumed. Iterators perform the step-by-step consumption and report each result through { value, done }. Symbol.iterator is the hook that lets an object provide an iterator. Generators provide a concise language feature for building iterators and lazy sequences, while async function* extends the same idea to values that arrive asynchronously.
Use the protocol deliberately: ordinary arrays and loops are usually the clearest choice for ordinary collections, while generators are a good fit when on-demand production, composition, or lifecycle-aware cleanup makes the problem easier to model.
