Iterator and Generator Patterns in JavaScript
Introduction
Iterators and generators are two sides of the same coin in JavaScript. An iterator generator pattern gives you sequential data access through a clean protocol, while standalone generator functions produce iterators with less boilerplate. Together they give you a standardized way to walk through data, no index variables, no manual length checks. Anything that implements the iterator protocol works with for...of, spread, destructuring, and Array.from.
The iterator protocol
An iterator is any object with a next() method that returns an iteration result object. That object must have two properties: value (the current element) and done (true when there is nothing left to iterate). The simplest possible iterator always says “done”, or never does.
const counter = {
next() {
return { value: 1, done: false };
}
};
The second part of the protocol is iterability. An object becomes iterable when it defines a method keyed by Symbol.iterator that returns an iterator. JavaScript’s built-in types like Array, String, Map, and Set all implement this, which is why you can spread them, destructure them, and loop over them directly. Plain objects are the notable exception: they skip the protocol entirely, so they are not iterable out of the box.
const isIterable = (obj) => Symbol.iterator in obj;
isIterable([1, 2, 3]); // true
isIterable('hello'); // true
isIterable(new Map()); // true
isIterable({}); // false
Consuming iterables
Once an object is iterable, JavaScript gives you several ways to consume it. Each approach calls Symbol.iterator under the hood, so the iteration strategy is consistent regardless of which syntax you pick.
for...of is the most common. It looks up the Symbol.iterator method once at the start and then calls next() until done is true. The loop body gets each unwrapped value in turn.
for (const item of ['a', 'b', 'c']) {
console.log(item); // a, b, c
}
The spread operator works on any iterable, not just arrays. When you spread a string, you get individual characters. When you spread a Set, you get its unique values. The spread operator calls Symbol.iterator and collects every value into the surrounding array or function argument list.
const chars = [...'abc']; // ['a', 'b', 'c']
Destructuring also understands iterables. The left-hand side pattern pulls values from the iterator one at a time, a rest element (...rest) grabs whatever remains after the named positions are filled. This works with any iterable, so you can destructure maps, sets, and custom iterables the same way you destructure arrays.
const [first, ...rest] = new Set([1, 2, 3]);
// first = 1, rest = [2, 3]
Array.from converts any iterable to an array, which is useful for iterables that don’t have array methods built in. A Map’s default iterator yields [key, value] pairs, so Array.from gives you the same structure as Object.entries, but preserves insertion order and works with non-string keys. You can also pass a mapping function as the second argument to transform each element during conversion.
const arr = Array.from(new Map([['a', 1], ['b', 2]]));
// [['a', 1], ['b', 2]]
Custom iterator implementations
You can make any object iterable by giving it a Symbol.iterator method. This is useful for your own data structures, linked lists, trees, ranges, and paginated views all benefit from native iteration support. The patterns below start simple and build toward reusable, class-based designs.
Object-based iterator
function createRange(start, end) {
let current = start;
const iterator = {
next() {
if (current < end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
iterator[Symbol.iterator] = function () {
return iterator;
};
return iterator;
}
const range = createRange(0, 3);
range.next(); // { value: 0, done: false }
range.next(); // { value: 1, done: false }
range.next(); // { value: 2, done: false }
range.next(); // { value: undefined, done: true }
The key detail here is that Symbol.iterator returns this (the iterator itself), so the iterator is its own iterable. This means for...of and manual next() calls share the same state. It works for one pass but trips you up if you try to iterate the same range twice.
Class-based iterator
A class wraps the iterator logic into a reusable blueprint. Instead of a factory function, the constructor stores the data and index, and Symbol.iterator returns a fresh object each time.
class Inventory {
constructor(items) {
this.items = items;
this.index = 0;
this[Symbol.iterator] = function () {
return {
next: () => {
if (this.index < this.items.length) {
return { value: this.items[this.index++], done: false };
}
return { value: undefined, done: true };
}
};
};
}
}
const inv = new Inventory(['sword', 'shield', 'potion']);
for (const item of inv) {
console.log(item); // sword, shield, potion
}
The for...of loop consumes the iterator completely. After the loop finishes, calling next() on the same iterator returns { value: undefined, done: true }, it does not reset. This is by design. If you need to iterate the same collection multiple times, each pass must create a fresh iterator.
To make an iterator reusable, delegate to the built-in iterator of a stored collection instead of managing your own index. Each call to Symbol.iterator returns a new, independent iterator object tied to the underlying data.
class ReusableInventory {
constructor(items) {
this.items = items;
this[Symbol.iterator] = function () {
const iterator = this.items[Symbol.iterator];
return iterator.call(this.items);
};
}
}
Generator functions
Generator functions (function*) give you a simpler way to build iterators. Instead of managing done manually, you yield values and the function pauses after each one. The runtime handles the protocol for you, the returned generator object already has next(), Symbol.iterator, and the { value, done } result shape.
function* simpleGen() {
yield 1;
yield 2;
yield 3;
}
const gen = simpleGen();
gen.next(); // { value: 1, done: false }
gen.next(); // { value: 2, done: false }
gen.next(); // { value: 3, done: false }
gen.next(); // { value: undefined, done: true }
Each call to a generator function creates a fresh Generator object with its own state. Calling simpleGen() twice gives you two independent generators that yield their own sequences without interfering with each other. This is one of the main advantages over hand-rolling an iterator: the state is automatically isolated per invocation.
Generator functions cannot be arrow functions. There is no async* => syntax either, the * is only valid on a named or anonymous function declaration. You can use generator shorthand inside objects and classes:
const obj = {
*gen() {
yield 'a';
yield 'b';
}
};
class Series {
*items() {
yield 1;
yield 2;
yield 3;
}
}
yield and yield*
yield pauses the generator and returns a value to the caller. When the caller invokes next() again, execution resumes right after the yield expression, with the argument to next() becoming the result of that expression. This two-way communication channel is what sets generators apart from plain iterators.
function* gen() {
const a = yield 'first';
console.log(a); // 'hello'
yield 'second';
}
const g = gen();
g.next(); // { value: 'first', done: false }
g.next('hello'); // resumes, a='hello', logs 'hello', pauses at next yield
g.next(); // { value: 'second', done: false }
g.next(); // { value: undefined, done: true }
The first next() call discards its argument. There is no prior yield expression to receive the value, so whatever you pass is silently ignored. If you need to seed a generator with initial data, pass it as a regular function parameter instead.
yield* delegates to another iterable. The outer generator produces every value from the inner one in order, then resumes its own body. This is different from calling the inner generator and manually iterating over it, yield* handles the plumbing so the outer generator transparently forwards all values.
function* inner() {
yield 'a';
yield 'b';
}
function* outer() {
yield* inner();
yield 'c';
}
[...outer()]; // ['a', 'b', 'c']
When the inner iterable is an array, yield* is shorthand for yielding each element individually, you don’t need a for...of loop. This makes recursive tree traversal clean: each node yields its value, then delegates to its children using yield*. The recursion unwinds naturally because each yield* pauses the parent generator while the child runs to completion.
function* flattenTree(node) {
if (node === null) return;
yield node.value;
yield* flattenTree(node.left);
yield* flattenTree(node.right);
}
Generator instance methods
Generators come with three instance methods that give you fine-grained control over execution. Beyond basic next(), you can inject values, inject errors, and terminate the generator early.
generator.next(value) resumes execution and sends a value to be assigned as the result of the current yield expression. The value arrives inside the generator body as though the yield expression evaluated to it:
function* counter() {
let n = 0;
while (true) {
const increment = yield n++;
if (increment !== undefined) n += increment;
}
}
const c = counter();
c.next(); // { value: 0, done: false }
c.next(10); // { value: 11, done: false } — skipped 1, added 10
c.next(); // { value: 12, done: false }
generator.throw(error) injects an error at the current yield point. If the generator catches it inside a try...catch, execution continues to the next yield. If there is no catch, the error propagates out and the generator terminates. This is a useful pattern for signalling that the consumer wants the generator to stop producing values under an error condition.
function* gen() {
try {
yield 'paused';
} catch (e) {
console.log('Caught:', e.message); // Caught: boom
}
yield 'resumed';
}
const g = gen();
g.next(); // { value: 'paused', done: false }
g.throw(new Error('boom')); // resumes, catches, logs, pauses at next yield
g.next(); // { value: 'resumed', done: false }
generator.return(value) immediately terminates the generator. The next call to next() returns { value: <your-value>, done: true }, signalling that the generator is finished, even if there are more yield statements in the body. Any finally blocks do run before termination, which means you can rely on them for cleanup logic like closing file handles or aborting network requests.
function* gen() {
try {
yield 1;
yield 2;
} finally {
console.log('cleanup');
}
}
const g = gen();
g.next(); // { value: 1, done: false }
g.return('early'); // logs 'cleanup', returns { value: 'early', done: true }
g.next(); // { value: undefined, done: true }
Async iterators and generators
ES2018 added async iterators for handling streams of asynchronous data. An object implements the async iterator protocol when it has a method keyed by Symbol.asyncIterator that returns an async iterator. In practice, that async iterator’s next() returns a Promise, and for await...of unwraps each resolved value before handing it to the loop body.
const asyncRange = {};
asyncRange[Symbol.asyncIterator] = function () {
let step = 0;
return {
next() {
return new Promise((resolve) => {
setTimeout(() => {
if (step < 3) {
resolve({ value: step++, done: false });
} else {
resolve({ value: undefined, done: true });
}
}, 50);
});
}
};
};
for await (const val of asyncRange) {
console.log(val); // 0, 1, 2
}
Writing async iterators by hand is verbose, you manage a counter, wrap every result in a Promise, and handle the protocol yourself. Async generator functions cut through that boilerplate. Inside async function*, you can use await and yield together. Each yield implicitly wraps the value in a resolved Promise, and the function body pauses cleanly at each await point.
async function* fetchPages(baseUrl) {
let page = 1;
while (page <= 3) {
const data = await fetch(`${baseUrl}?page=${page}`).then(r => r.json());
yield data;
page++;
}
}
for await (const pg of fetchPages('/api/data')) {
console.log(pg);
}
One key difference from regular generators: yield without await inside an async function* returns a Promise-wrapped value. You need for await...of or manual .next() awaiting to consume it.
Practical Patterns
Infinite sequences
Generators are ideal for infinite sequences because they generate values on demand rather than storing them all in memory:
function* fibonacci() {
let [a, b] = [0, 1];
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
const fib = fibonacci();
fib.next().value; // 0
fib.next().value; // 1
fib.next().value; // 1
fib.next().value; // 2
Spreading or Array.from-ing an infinite generator will hang your program, it never reaches done, so the consumer keeps pulling values forever. To safely extract a finite slice, wrap the iterator in a take() helper that caps the number of pulls. This pattern keeps the lazy nature of the generator while putting a hard limit on consumption.
function take(iterator, count) {
const limited = {
next() {
if (count > 0) {
count--;
return iterator.next();
}
return { value: undefined, done: true };
}
};
limited[Symbol.iterator] = function () {
return limited;
};
return limited;
}
[...take(fibonacci(), 5)]; // [0, 1, 1, 2, 3]
The take() helper wraps any iterator and stops after a fixed count. Because it implements Symbol.iterator by returning itself, it works transparently with spread, for...of, and Array.from. This same wrapping pattern extends to filtering, mapping, and chaining operations on lazy sequences.
Pagination helper
Generator functions shine for pagination because they let you slice a large dataset into manageable chunks without materializing the whole thing at once. The caller pulls one page at a time, and the generator handles the offset arithmetic internally.
function* paginate(items, pageSize = 10) {
for (let i = 0; i < items.length; i += pageSize) {
yield items.slice(i, i + pageSize);
}
}
for (const page of paginate(Array.from({ length: 55 }, (_, i) => i), 20)) {
console.log(page.length); // 20, 20, 15
}
The paginate generator wraps Array.prototype.slice, which returns a shallow copy, so each page is an independent snapshot of that range. For very large datasets, pair pagination with a data-fetching layer so you never hold the full collection in memory at once.
Tree walking with generators
Recursive generators are a compact way to traverse tree structures. Each node yields itself, then delegates to its children. The yield* expression suspends the current generator while the subtree’s generator runs to completion, producing a flat depth-first walk.
function* walkTree(node) {
if (!node) return;
yield node;
yield* walkTree(node.left);
yield* walkTree(node.right);
}
Linked list iterator
A linked list is a natural fit for generators because each node points to the next, the iteration order is embedded in the data structure itself. Using a generator function as Symbol.iterator means you walk the chain by following .next pointers and yielding each value without exposing the internal node objects.
class ListNode {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class LinkedList {
constructor(head) {
this.head = head;
this[Symbol.iterator] = function* () {
let current = this.head;
while (current) {
yield current.value;
current = current.next;
}
};
}
}
const list = new LinkedList(
new ListNode(1, new ListNode(2, new ListNode(3)))
);
[...list]; // [1, 2, 3]
Common Pitfalls
Single-use iterators. Iterators and generators are consumed once. After a for...of loop finishes, calling next() again returns { value: undefined, done: true }. If you need multiple passes, spread into an array or call the generator factory each time.
Infinite generators and spread. Spreading an infinite generator hangs indefinitely. Always limit infinite generators with a take() helper or a break condition in a for...of.
First next() argument is ignored. The first call to next() on a generator has no prior yield to receive its argument. This is by design, the first call initializes the generator body up to the first yield.
Async iterator next() must return a Promise. If your async iterator’s next() returns a plain object synchronously, for await...of will fail silently or behave unpredictably. Always wrap the return in new Promise(...).
Return values are discarded by next(). When a generator returns a value (via return or falling through to the end), next() still returns { value: undefined, done: true }. The return value is only visible when you call generator.return(value) from outside.
Reach for iterators when order matters
Iterators are most helpful when the shape of the data is sequential and the code should consume it one value at a time. That could be a list of results, a tree walk, or a stream of values that should not be materialized all at once. The iterator protocol gives you a standard way to expose that sequence without forcing callers to know how it is stored internally.
Generators make that pattern easier to write because the control flow reads like the order in which values appear. You yield one item, pause, and resume later with the next item. That simple structure is a good fit for lazy traversal and for data that naturally arrives step by step. If the sequence is small and static, an array is still fine, but when the order itself is the point, iterators are worth reaching for.
Summary
Iterators give JavaScript a standardized way to expose sequential data. The iterator protocol requires a next() method returning { value, done }, and objects become iterable by defining a method keyed by Symbol.iterator.
Generators simplify iterator implementation using function* and yield. Each call to a generator function returns a fresh iterator with its own state. yield* delegates to other iterables, making recursive traversal of trees and nested structures clean.
Async iterators and async generators (ES2018) handle asynchronous data streams using methods keyed by Symbol.asyncIterator and async function*, consumed with for await...of.
These patterns shine for lazy evaluation, infinite sequences, tree traversal, and building composable data pipelines.
Next steps
- Try replacing a manual
forloop in your codebase with a generator to see where the pattern simplifies control flow - Build a custom iterable for a data structure you already own, a queue, a tree, or a paginated API response
- Explore async generators for streaming fetch results, reading large files in chunks, or polling an API on a timer
- Read the MDN iteration protocols reference for edge cases and browser compatibility notes
See Also
- Strategy Pattern - Learn how strategy lets you swap algorithms at runtime
- State Machines - Model state transitions with explicit states and events
- Symbol Type - Reference for Symbol.iterator, Symbol.asyncIterator, and other well-known symbols