Iterators, Generators, and Custom Iteration
I was asked to implement a lazy pagination function in an interview: return results one page at a time without loading everything into memory. I knew async/await but I had never touched generators. I stumbled through a solution using closures and state variables that worked but looked nothing like idiomatic JavaScript. A generator would have made it trivial. This lesson fills that gap.
The Iteration Protocol
JavaScript has a formal protocol for making objects iterable. It consists of two interfaces:
Iterable: An object that has a [Symbol.iterator]() method, which returns an iterator.
Iterator: An object with a next() method that returns { value: T, done: boolean }.
When you write for...of, JavaScript calls [Symbol.iterator]() on the right-hand side to get an iterator, then calls next() repeatedly until done is true.
Arrays, strings, Maps, and Sets are all built-in iterables. You can make any object iterable by implementing the protocol.
Implementing a Custom Iterable
Let us implement a Range that iterates from start to end, inclusive.
interface Range extends Iterable<number> {
start: number;
end: number;
}
function createRange(start: number, end: number): Range {
return {
start,
end,
[Symbol.iterator](): Iterator<number> {
let current = start;
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined as unknown as number, done: true };
},
};
},
};
}
const range = createRange(1, 5);
for (const n of range) {
console.log(n); // 1, 2, 3, 4, 5
}
// Also works with spread and destructuring
console.log([...createRange(1, 3)]); // [1, 2, 3]
const [first, second] = createRange(10, 20);
console.log(first, second); // 10, 11
Implementing a Custom Iterable: Linked List
A linked list is a great example because the iteration logic is non-trivial, and it shows that the iterator is separate from the data structure.
interface ListNode<T> {
value: T;
next: ListNode<T> | null;
}
class LinkedList<T> implements Iterable<T> {
private head: ListNode<T> | null = null;
private tail: ListNode<T> | null = null;
push(value: T): this {
const node: ListNode<T> = { value, next: null };
if (this.tail === null) {
this.head = node;
this.tail = node;
} else {
this.tail.next = node;
this.tail = node;
}
return this;
}
[Symbol.iterator](): Iterator<T> {
let current = this.head;
return {
next(): IteratorResult<T> {
if (current !== null) {
const value = current.value;
current = current.next;
return { value, done: false };
}
return { value: undefined as unknown as T, done: true };
},
};
}
}
const list = new LinkedList<string>();
list.push("a").push("b").push("c");
for (const item of list) {
console.log(item); // a, b, c
}
console.log([...list]); // ['a', 'b', 'c']
Generator Functions
Writing manual iterators is verbose. Generator functions (function*) give you a much cleaner syntax for the same thing. A generator function returns a generator object that implements both Iterable and Iterator.
The yield keyword pauses the generator and emits a value. The generator resumes from that exact point on the next next() call.
function* range(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) {
yield i;
}
}
for (const n of range(1, 5)) {
console.log(n); // 1, 2, 3, 4, 5
}
That range generator is functionally identical to the createRange from before, but the code is about a third of the size and much easier to read.
Lazy Infinite Sequences
Because generators pause at each yield, they can represent infinite sequences without consuming infinite memory. Values are computed only when requested.
function* naturals(): Generator<number> {
let n = 1;
while (true) {
yield n++;
}
}
function* take<T>(gen: Generator<T>, count: number): Generator<T> {
let taken = 0;
for (const value of gen) {
if (taken >= count) return;
yield value;
taken++;
}
}
function* filter<T>(gen: Generator<T>, predicate: (v: T) => boolean): Generator<T> {
for (const value of gen) {
if (predicate(value)) yield value;
}
}
// Compose: take the first 5 even natural numbers
const firstFiveEvens = take(
filter(naturals(), (n) => n % 2 === 0),
5
);
console.log([...firstFiveEvens]); // [2, 4, 6, 8, 10]
This is lazy evaluation. naturals() never computes more values than needed. The entire chain runs on demand, one value at a time.
Yield Delegation with yield*
yield* delegates to another iterable (or generator), forwarding all its values.
function* flatten<T>(nested: Iterable<Iterable<T>>): Generator<T> {
for (const inner of nested) {
yield* inner; // Delegate to each inner iterable
}
}
const data = [[1, 2], [3, 4], [5, 6]];
console.log([...flatten(data)]); // [1, 2, 3, 4, 5, 6]
yield* is essentially a loop of yield calls, but it also correctly forwards return values from sub-generators, which matters in more complex coroutine patterns.
Two-Way Communication with Generators
next() can pass a value back into the generator. Whatever you pass to next(value) becomes the result of the yield expression inside the generator.
function* logger(): Generator<void, void, string> {
while (true) {
const message = yield; // receives the value passed to next()
console.log(`[LOG] ${message}`);
}
}
const log = logger();
log.next(); // Start the generator: runs until first yield
log.next("Hello"); // [LOG] Hello
log.next("World"); // [LOG] World
This two-way flow is how generators can be used as coroutines. Libraries like Redux-Saga used this pattern heavily before async/await became standard.
Async Generators
Async generators combine async functions with generators. They produce values asynchronously and are consumed with for await...of.
async function* paginate<T>(
fetchPage: (page: number) => Promise<T[]>,
totalPages: number
): AsyncGenerator<T[]> {
for (let page = 1; page <= totalPages; page++) {
const results = await fetchPage(page);
yield results;
}
}
// Simulate a paginated API
async function fetchUsers(page: number): Promise<{ id: number; name: string }[]> {
// In real code, this would be an API call
return [
{ id: (page - 1) * 2 + 1, name: `User${(page - 1) * 2 + 1}` },
{ id: (page - 1) * 2 + 2, name: `User${(page - 1) * 2 + 2}` },
];
}
async function main(): Promise<void> {
for await (const page of paginate(fetchUsers, 3)) {
console.log("Page:", page);
}
}
main();
// Page: [{ id: 1, name: 'User1' }, { id: 2, name: 'User2' }]
// Page: [{ id: 3, name: 'User3' }, { id: 4, name: 'User4' }]
// Page: [{ id: 5, name: 'User5' }, { id: 6, name: 'User6' }]
This is the correct pattern for lazy data loading in Node.js. Each page is fetched only when the loop requests the next value.
for...of vs for...in
This is a trap that appears in interviews.
for...of uses the iteration protocol. It calls [Symbol.iterator]() and iterates values.
for...in iterates the enumerable property keys of an object, including inherited ones. It is designed for plain objects, not arrays.
const arr = ["a", "b", "c"];
for (const value of arr) {
console.log(value); // 'a', 'b', 'c': the values you expect
}
for (const key in arr) {
console.log(key); // '0', '1', '2': string keys, not values
}
// The real danger: for...in picks up inherited properties
(Array.prototype as unknown as Record<string, unknown>).customMethod = function () {};
for (const key in arr) {
console.log(key); // '0', '1', '2', 'customMethod': customMethod leaks in!
}
Never use for...in on arrays. Use for...of for iteration. Use for...in only for plain object key enumeration, and even then, Object.keys() is usually safer because it excludes inherited properties.
Destructuring with Custom Iterables
Destructuring uses the iteration protocol, so it works with any iterable, including your custom ones.
function* coordinates(): Generator<number> {
yield 10; // x
yield 20; // y
yield 30; // z
}
const [x, y, z] = coordinates();
console.log(x, y, z); // 10 20 30
// Skip values with holes
const [, second, , fourth] = [1, 2, 3, 4];
console.log(second, fourth); // 2 4
// Rest in destructuring
const [head, ...tail] = createRange(1, 5);
console.log(head); // 1
console.log(tail); // [2, 3, 4, 5]: spread into array
Note that the rest element (...tail) collects into a plain array. The spread operator also uses [Symbol.iterator], so spreading a generator exhausts it.
Key insight: Generators are state machines that suspend at yield and resume on next(). They make the iteration protocol easy to implement, enable lazy computation of infinite sequences, and form the basis of async iteration in Node.js. Any time you need to produce values one at a time or process a stream lazily, a generator is the right tool.