JavaScript Essentials

Iterators & Generators

50 min Lesson 36 of 60

The Iteration Protocol in JavaScript

JavaScript provides a standardized way for objects to define or customize their iteration behavior. This is known as the iteration protocol, and it consists of two sub-protocols: the iterable protocol and the iterator protocol. These protocols are not built-in implementations or syntax -- they are conventions that any object can follow. When an object follows these conventions, it can be used with language features like for...of loops, the spread operator (...), destructuring assignments, and many built-in APIs that expect iterable data.

Before the iteration protocol was introduced in ES6, there was no unified way to iterate over different data structures. Arrays used index-based loops, objects used for...in, and custom data structures required their own iteration methods. The iteration protocol solves this by providing a single, consistent interface that all iterable objects share. Understanding iterators and generators is essential for writing clean, efficient code that works seamlessly with JavaScript's built-in iteration mechanisms.

Symbol.iterator -- The Key to Iterability

The iterable protocol requires an object to implement a method accessible through the key Symbol.iterator. This method must return an iterator object. When JavaScript needs to iterate over an object (for example, in a for...of loop), it calls the object's [Symbol.iterator]() method to get an iterator, and then uses that iterator to retrieve values one at a time.

Example: How Built-in Iterables Work

// Arrays are iterable
const colors = ['red', 'green', 'blue'];

// Get the iterator from the array
const iterator = colors[Symbol.iterator]();

// Manually call next() to get values
console.log(iterator.next()); // { value: 'red', done: false }
console.log(iterator.next()); // { value: 'green', done: false }
console.log(iterator.next()); // { value: 'blue', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

// Strings are also iterable
const word = 'Hello';
const strIterator = word[Symbol.iterator]();
console.log(strIterator.next()); // { value: 'H', done: false }
console.log(strIterator.next()); // { value: 'e', done: false }

// Maps and Sets are iterable too
const map = new Map([['a', 1], ['b', 2]]);
const set = new Set([10, 20, 30]);

for (const [key, value] of map) {
    console.log(key + ' = ' + value);
}

for (const item of set) {
    console.log(item);
}
Note: Plain objects ({}) are NOT iterable by default. They do not have a [Symbol.iterator] method. If you try to use for...of on a plain object, you will get a TypeError. However, you can make any object iterable by implementing the [Symbol.iterator] method, as we will see shortly.

Iterable Objects -- What Can Be Iterated

JavaScript has several built-in iterable types. Understanding which types are iterable helps you know what works with for...of and the spread operator.

Example: Built-in Iterables

// 1. Arrays
for (const item of [1, 2, 3]) {
    console.log(item); // 1, 2, 3
}

// 2. Strings
for (const char of 'abc') {
    console.log(char); // 'a', 'b', 'c'
}

// 3. Maps
const map = new Map([['x', 10], ['y', 20]]);
for (const [key, val] of map) {
    console.log(key, val); // 'x' 10, 'y' 20
}

// 4. Sets
const set = new Set(['apple', 'banana', 'cherry']);
for (const fruit of set) {
    console.log(fruit);
}

// 5. TypedArrays
const typed = new Uint8Array([255, 128, 0]);
for (const byte of typed) {
    console.log(byte); // 255, 128, 0
}

// 6. arguments object (inside functions)
function showArgs() {
    for (const arg of arguments) {
        console.log(arg);
    }
}
showArgs('a', 'b', 'c');

// 7. NodeList (from DOM queries -- in browser environments)
// for (const el of document.querySelectorAll('p')) {
//     console.log(el.textContent);
// }

// Using iterables with spread and destructuring
const letters = ['a', 'b', 'c', 'd', 'e'];
const [first, second, ...rest] = letters;
console.log(first);  // 'a'
console.log(second); // 'b'
console.log(rest);   // ['c', 'd', 'e']

const merged = [...new Set([1, 2, 2, 3, 3, 3])];
console.log(merged); // [1, 2, 3]

The Iterator Protocol -- The next() Method

An iterator is any object that implements the iterator protocol. This protocol requires a single method: next(). Each call to next() returns an object with two properties: value (the current value in the iteration) and done (a boolean indicating whether the iteration has finished). When done is true, the iteration is complete, and the value is typically undefined.

Example: Building an Iterator from Scratch

function createCountdown(start) {
    let current = start;

    return {
        next: function() {
            if (current >= 0) {
                return { value: current--, done: false };
            }
            return { value: undefined, done: true };
        }
    };
}

const countdown = createCountdown(3);
console.log(countdown.next()); // { value: 3, done: false }
console.log(countdown.next()); // { value: 2, done: false }
console.log(countdown.next()); // { value: 1, done: false }
console.log(countdown.next()); // { value: 0, done: false }
console.log(countdown.next()); // { value: undefined, done: true }
console.log(countdown.next()); // { value: undefined, done: true }

Creating Custom Iterables

To make an object work with for...of, the spread operator, and destructuring, you need to implement the [Symbol.iterator] method on it. This method must return an iterator object (an object with a next() method). The iterator controls what values are produced and in what order.

Example: Custom Iterable Range Object

class Range {
    constructor(start, end, step = 1) {
        this.start = start;
        this.end = end;
        this.step = step;
    }

    [Symbol.iterator]() {
        let current = this.start;
        const end = this.end;
        const step = this.step;

        return {
            next() {
                if (current <= end) {
                    const value = current;
                    current += step;
                    return { value: value, done: false };
                }
                return { value: undefined, done: true };
            }
        };
    }
}

const range = new Range(1, 10, 2);

// Works with for...of
for (const num of range) {
    console.log(num); // 1, 3, 5, 7, 9
}

// Works with spread
const numbers = [...new Range(0, 5)];
console.log(numbers); // [0, 1, 2, 3, 4, 5]

// Works with destructuring
const [a, b, c] = new Range(100, 500, 100);
console.log(a, b, c); // 100 200 300

// Works with Array.from()
const evens = Array.from(new Range(2, 20, 2));
console.log(evens); // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

Example: Iterable Linked List

class Node {
    constructor(value, next = null) {
        this.value = value;
        this.next = next;
    }
}

class LinkedList {
    constructor() {
        this.head = null;
        this.size = 0;
    }

    append(value) {
        const newNode = new Node(value);
        if (!this.head) {
            this.head = newNode;
        } else {
            let current = this.head;
            while (current.next) {
                current = current.next;
            }
            current.next = newNode;
        }
        this.size++;
        return this;
    }

    [Symbol.iterator]() {
        let current = this.head;

        return {
            next() {
                if (current) {
                    const value = current.value;
                    current = current.next;
                    return { value: value, done: false };
                }
                return { value: undefined, done: true };
            }
        };
    }
}

const list = new LinkedList();
list.append('first').append('second').append('third');

// Iterate with for...of
for (const item of list) {
    console.log(item); // 'first', 'second', 'third'
}

// Convert to array with spread
const arr = [...list];
console.log(arr); // ['first', 'second', 'third']

// Use with destructuring
const [head, ...tail] = list;
console.log(head); // 'first'
console.log(tail); // ['second', 'third']
Pro Tip: When implementing [Symbol.iterator], the returned iterator object can also be iterable itself by adding a [Symbol.iterator] method that returns this. This is a common pattern that allows iterators to be used directly in for...of loops. All built-in iterators follow this pattern.

Generators -- The function* Syntax

Generators are a special type of function in JavaScript that can pause execution and resume later. They are defined using the function* syntax (note the asterisk) and use the yield keyword to produce values. When called, a generator function does not execute its body immediately. Instead, it returns a generator object that conforms to both the iterable and iterator protocols. This makes generators an incredibly powerful tool for creating iterators with minimal code.

Example: Your First Generator

function* simpleGenerator() {
    console.log('Start');
    yield 1;
    console.log('After first yield');
    yield 2;
    console.log('After second yield');
    yield 3;
    console.log('End');
}

const gen = simpleGenerator();

// Nothing is logged yet -- the function body has not executed
console.log(gen.next());
// Logs: "Start"
// Returns: { value: 1, done: false }

console.log(gen.next());
// Logs: "After first yield"
// Returns: { value: 2, done: false }

console.log(gen.next());
// Logs: "After second yield"
// Returns: { value: 3, done: false }

console.log(gen.next());
// Logs: "End"
// Returns: { value: undefined, done: true }

The yield Keyword

The yield keyword is the heart of generators. It pauses the generator's execution and produces a value to the caller. When next() is called again, execution resumes from the point immediately after the yield that paused it. The yield keyword can also receive values from outside the generator, which we will explore shortly.

Example: yield with Expressions and Logic

function* fibonacci() {
    let a = 0;
    let b = 1;

    while (true) {
        yield a;
        const next = a + b;
        a = b;
        b = next;
    }
}

const fib = fibonacci();

// Get the first 10 Fibonacci numbers
const first10 = [];
for (let i = 0; i < 10; i++) {
    first10.push(fib.next().value);
}
console.log(first10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

// yield can receive values from next()
function* accumulator() {
    let total = 0;
    while (true) {
        const value = yield total;
        if (value === null || value === undefined) break;
        total += value;
    }
    return total;
}

const acc = accumulator();
console.log(acc.next());      // { value: 0, done: false } -- initial
console.log(acc.next(10));    // { value: 10, done: false }
console.log(acc.next(20));    // { value: 30, done: false }
console.log(acc.next(5));     // { value: 35, done: false }
console.log(acc.next(null));  // { value: 35, done: true }
Note: The first call to next() on a generator starts the execution from the beginning of the function until the first yield. Any value passed to the first next() call is discarded because there is no yield expression waiting to receive it. Values passed to subsequent next() calls become the result of the yield expression that paused the generator.

Generator as Iterator

Generator objects automatically implement both the iterable and iterator protocols. This means you can use them directly with for...of loops, the spread operator, and destructuring, making generators the simplest way to create custom iterables.

Example: Generator-Powered Iteration

function* range(start, end, step = 1) {
    for (let i = start; i <= end; i += step) {
        yield i;
    }
}

// Use with for...of
for (const num of range(1, 5)) {
    console.log(num); // 1, 2, 3, 4, 5
}

// Use with spread
const nums = [...range(0, 100, 10)];
console.log(nums); // [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

// Use with destructuring
const [first, second, third] = range(5, 50, 5);
console.log(first, second, third); // 5 10 15

// Compare: implementing Range with a generator vs manually
class EasyRange {
    constructor(start, end) {
        this.start = start;
        this.end = end;
    }

    *[Symbol.iterator]() {
        for (let i = this.start; i <= this.end; i++) {
            yield i;
        }
    }
}

const myRange = new EasyRange(1, 5);
console.log([...myRange]); // [1, 2, 3, 4, 5]
console.log([...myRange]); // [1, 2, 3, 4, 5] -- works multiple times!

Generator with return

A generator can use the return statement to end iteration and optionally provide a final value. When a generator returns, the resulting next() call produces { value: returnValue, done: true }. However, the return value is NOT included when using for...of or the spread operator, as those stop as soon as done is true.

Example: return in Generators

function* generatorWithReturn() {
    yield 'first';
    yield 'second';
    return 'final'; // This ends the generator
    yield 'never reached'; // This line never executes
}

const gen = generatorWithReturn();
console.log(gen.next()); // { value: 'first', done: false }
console.log(gen.next()); // { value: 'second', done: false }
console.log(gen.next()); // { value: 'final', done: true }
console.log(gen.next()); // { value: undefined, done: true }

// IMPORTANT: for...of does NOT include the return value
for (const val of generatorWithReturn()) {
    console.log(val);
}
// Output: 'first', 'second'
// 'final' is NOT logged because done is true

// Spread also ignores the return value
console.log([...generatorWithReturn()]); // ['first', 'second']

// The return() method can force-close a generator
function* counting() {
    let i = 0;
    try {
        while (true) {
            yield i++;
        }
    } finally {
        console.log('Generator closed at ' + i);
    }
}

const counter = counting();
console.log(counter.next()); // { value: 0, done: false }
console.log(counter.next()); // { value: 1, done: false }
console.log(counter.return('done'));
// Logs: "Generator closed at 2"
// Returns: { value: 'done', done: true }

yield* Delegation

The yield* expression delegates iteration to another iterable or generator. When the engine encounters yield*, it iterates over the given iterable and yields each value one by one. This is powerful for composing generators from smaller pieces and for flattening nested iterables.

Example: yield* for Delegation

function* inner() {
    yield 'a';
    yield 'b';
    return 'inner done'; // Return value of delegated generator
}

function* outer() {
    yield 1;
    const result = yield* inner(); // Delegate to inner
    console.log('inner returned: ' + result);
    yield 2;
}

const gen = outer();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 'a', done: false }
console.log(gen.next()); // { value: 'b', done: false }
// Logs: "inner returned: inner done"
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: undefined, done: true }

// yield* works with any iterable, not just generators
function* concatIterables(...iterables) {
    for (const iterable of iterables) {
        yield* iterable;
    }
}

const combined = [...concatIterables([1, 2], 'ab', new Set([true, false]))];
console.log(combined); // [1, 2, 'a', 'b', true, false]

// Practical: flatten nested arrays recursively
function* flatten(arr) {
    for (const item of arr) {
        if (Array.isArray(item)) {
            yield* flatten(item); // Recursive delegation
        } else {
            yield item;
        }
    }
}

const nested = [1, [2, [3, 4]], [5, [6, [7]]]];
console.log([...flatten(nested)]); // [1, 2, 3, 4, 5, 6, 7]
Pro Tip: The yield* expression evaluates to the return value of the delegated generator (the value passed to its return statement). This can be useful for composing generators that communicate results back to the delegating generator.

Infinite Generators

Because generators are lazy -- they only produce values when asked -- they can represent infinite sequences without consuming infinite memory. Each call to next() computes and returns just one value. This makes generators ideal for representing mathematical sequences, continuous data streams, unique ID generators, and other potentially unbounded data sources.

Example: Infinite Generators

// Infinite counter
function* naturalNumbers() {
    let n = 1;
    while (true) {
        yield n++;
    }
}

// Infinite ID generator
function* idGenerator(prefix = 'id') {
    let counter = 0;
    while (true) {
        yield prefix + '_' + (++counter).toString().padStart(6, '0');
    }
}

const ids = idGenerator('user');
console.log(ids.next().value); // "user_000001"
console.log(ids.next().value); // "user_000002"
console.log(ids.next().value); // "user_000003"

// You must limit consumption of infinite generators!
function take(n, iterable) {
    const result = [];
    let count = 0;
    for (const item of iterable) {
        if (count >= n) break;
        result.push(item);
        count++;
    }
    return result;
}

console.log(take(5, naturalNumbers())); // [1, 2, 3, 4, 5]

// Infinite prime number generator
function* primes() {
    const found = [];
    let candidate = 2;

    while (true) {
        let isPrime = true;
        for (const prime of found) {
            if (prime * prime > candidate) break;
            if (candidate % prime === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) {
            found.push(candidate);
            yield candidate;
        }
        candidate++;
    }
}

console.log(take(10, primes())); // [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Warning: Never use the spread operator or Array.from() on an infinite generator without limiting it first. Expressions like [...naturalNumbers()] will attempt to consume the entire infinite sequence, causing your program to hang and eventually run out of memory. Always use a limiting function like take() or a for loop with a break condition when consuming infinite generators.

Lazy Evaluation with Generators

Lazy evaluation means computing values only when they are needed, rather than computing them all upfront. Generators naturally support lazy evaluation because they produce values on demand. This is especially valuable when working with large datasets or expensive computations where you may not need all the results.

Example: Lazy Pipeline with Generators

// Generator-based utility functions for lazy pipelines
function* map(iterable, fn) {
    for (const item of iterable) {
        yield fn(item);
    }
}

function* filter(iterable, predicate) {
    for (const item of iterable) {
        if (predicate(item)) {
            yield item;
        }
    }
}

function* takeWhile(iterable, predicate) {
    for (const item of iterable) {
        if (!predicate(item)) return;
        yield item;
    }
}

function* chunk(iterable, size) {
    let batch = [];
    for (const item of iterable) {
        batch.push(item);
        if (batch.length === size) {
            yield batch;
            batch = [];
        }
    }
    if (batch.length > 0) {
        yield batch;
    }
}

// Build a lazy pipeline: natural numbers -> squares -> filter odd -> take while < 1000
function* naturalNumbers() {
    let n = 1;
    while (true) yield n++;
}

const pipeline = takeWhile(
    filter(
        map(naturalNumbers(), function(n) { return n * n; }),
        function(n) { return n % 2 !== 0; }
    ),
    function(n) { return n < 1000; }
);

// No computation happens until we consume the pipeline
const result = [...pipeline];
console.log(result);
// [1, 9, 25, 49, 81, 121, 169, 225, 289, 361, 441, 529, 625, 729, 841, 961]

// Chunking example
const batches = [...chunk([1, 2, 3, 4, 5, 6, 7], 3)];
console.log(batches); // [[1, 2, 3], [4, 5, 6], [7]]

Generators for Async Patterns (Introduction)

Before async/await was added to JavaScript, generators were used as the foundation for writing asynchronous code that looked synchronous. While async/await has largely replaced this pattern, understanding how generators handle async operations gives you deeper insight into how async/await works under the hood, since it is essentially built on the same concept of pausing and resuming execution.

Example: Generators and Promises (Conceptual)

// Simulated async operations
function fetchUser(id) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve({ id: id, name: 'User ' + id });
        }, 100);
    });
}

function fetchPosts(userId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve([
                { id: 1, title: 'Post by user ' + userId },
                { id: 2, title: 'Another post by user ' + userId }
            ]);
        }, 100);
    });
}

// Generator-based async flow
function* userFlow(userId) {
    const user = yield fetchUser(userId);
    console.log('Got user:', user.name);

    const posts = yield fetchPosts(user.id);
    console.log('Got posts:', posts.length);

    return { user: user, posts: posts };
}

// Runner that executes the generator step by step
function runGenerator(generatorFn) {
    const args = Array.prototype.slice.call(arguments, 1);
    const gen = generatorFn.apply(null, args);

    return new Promise(function(resolve, reject) {
        function step(nextFn) {
            let result;
            try {
                result = nextFn();
            } catch (e) {
                return reject(e);
            }
            if (result.done) {
                return resolve(result.value);
            }
            Promise.resolve(result.value).then(
                function(value) {
                    step(function() { return gen.next(value); });
                },
                function(err) {
                    step(function() { return gen.throw(err); });
                }
            );
        }
        step(function() { return gen.next(); });
    });
}

// Usage -- looks synchronous but is fully async
runGenerator(userFlow, 42).then(function(data) {
    console.log('Final result:', data);
});

// The modern equivalent using async/await:
// async function userFlow(userId) {
//     const user = await fetchUser(userId);
//     const posts = await fetchPosts(user.id);
//     return { user, posts };
// }
Note: The generator-based async pattern shown above is the conceptual foundation of async/await. When you write async function, the JavaScript engine essentially creates a state machine similar to a generator. The await keyword works like yield -- it pauses execution until the promise resolves, then resumes with the resolved value. Understanding this connection deepens your knowledge of how asynchronous JavaScript works.

Real-World Examples

Generators and iterators are not just theoretical concepts. They have practical applications in everyday JavaScript development. Here are several real-world examples that demonstrate their power.

Fibonacci Sequence Generator

Example: Fibonacci with Memoization

function* fibonacci() {
    let prev = 0;
    let curr = 1;

    yield prev;
    yield curr;

    while (true) {
        const next = prev + curr;
        yield next;
        prev = curr;
        curr = next;
    }
}

// Get specific Fibonacci numbers
function nthFibonacci(n) {
    let count = 0;
    for (const fib of fibonacci()) {
        if (count === n) return fib;
        count++;
    }
}

console.log(nthFibonacci(0));  // 0
console.log(nthFibonacci(1));  // 1
console.log(nthFibonacci(10)); // 55
console.log(nthFibonacci(20)); // 6765

// Generate a range of Fibonacci numbers
function fibRange(start, count) {
    const result = [];
    let index = 0;
    for (const fib of fibonacci()) {
        if (index >= start && result.length < count) {
            result.push(fib);
        }
        if (result.length === count) break;
        index++;
    }
    return result;
}

console.log(fibRange(5, 8)); // [5, 8, 13, 21, 34, 55, 89, 144]

Paginated Data Fetcher

Example: Generator for Paginated API Data

// Simulated paginated API
function fetchPage(page, pageSize) {
    // Simulate API response with total of 47 items
    const totalItems = 47;
    const start = (page - 1) * pageSize;
    const items = [];

    for (let i = start; i < Math.min(start + pageSize, totalItems); i++) {
        items.push({ id: i + 1, name: 'Item ' + (i + 1) });
    }

    return Promise.resolve({
        data: items,
        page: page,
        totalPages: Math.ceil(totalItems / pageSize),
        hasMore: page * pageSize < totalItems
    });
}

// Async generator for paginated data (ES2018)
async function* paginatedFetch(pageSize) {
    let page = 1;
    let hasMore = true;

    while (hasMore) {
        const response = await fetchPage(page, pageSize);
        yield* response.data; // Yield each item individually
        hasMore = response.hasMore;
        page++;
    }
}

// Usage: iterate over ALL items across ALL pages seamlessly
async function processAllItems() {
    let count = 0;
    for await (const item of paginatedFetch(10)) {
        count++;
        if (count <= 3) {
            console.log(item); // First 3 items
        }
    }
    console.log('Total items processed: ' + count);
}

processAllItems();
// { id: 1, name: 'Item 1' }
// { id: 2, name: 'Item 2' }
// { id: 3, name: 'Item 3' }
// Total items processed: 47

Tree Traversal Generator

Example: Generator for Tree Traversal

class TreeNode {
    constructor(value, children = []) {
        this.value = value;
        this.children = children;
    }

    // Depth-first traversal using generator
    *depthFirst() {
        yield this.value;
        for (const child of this.children) {
            yield* child.depthFirst();
        }
    }

    // Breadth-first traversal using generator
    *breadthFirst() {
        const queue = [this];
        while (queue.length > 0) {
            const node = queue.shift();
            yield node.value;
            for (const child of node.children) {
                queue.push(child);
            }
        }
    }
}

const tree = new TreeNode('root', [
    new TreeNode('A', [
        new TreeNode('A1'),
        new TreeNode('A2')
    ]),
    new TreeNode('B', [
        new TreeNode('B1', [
            new TreeNode('B1a'),
            new TreeNode('B1b')
        ]),
        new TreeNode('B2')
    ]),
    new TreeNode('C')
]);

console.log('Depth-first:');
console.log([...tree.depthFirst()]);
// ['root', 'A', 'A1', 'A2', 'B', 'B1', 'B1a', 'B1b', 'B2', 'C']

console.log('Breadth-first:');
console.log([...tree.breadthFirst()]);
// ['root', 'A', 'B', 'C', 'A1', 'A2', 'B1', 'B2', 'B1a', 'B1b']

Stateful Processing Pipeline

Example: Log File Parser with Generators

// Simulate reading log lines
function* logLines() {
    yield '2025-01-15T10:30:00 INFO Server started on port 3000';
    yield '2025-01-15T10:30:05 DEBUG Connection pool initialized';
    yield '2025-01-15T10:31:00 ERROR Database connection timeout';
    yield '2025-01-15T10:31:01 WARN Retrying database connection';
    yield '2025-01-15T10:31:02 INFO Database connected';
    yield '2025-01-15T10:32:00 ERROR File not found: /api/data';
    yield '2025-01-15T10:33:00 INFO Request processed in 45ms';
}

// Parse each line into structured data
function* parseLog(lines) {
    for (const line of lines) {
        const match = line.match(
            /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s+(INFO|DEBUG|WARN|ERROR)\s+(.+)$/
        );
        if (match) {
            yield {
                timestamp: match[1],
                level: match[2],
                message: match[3]
            };
        }
    }
}

// Filter by log level
function* filterByLevel(entries, level) {
    for (const entry of entries) {
        if (entry.level === level) {
            yield entry;
        }
    }
}

// Lazy pipeline: read -> parse -> filter
const errors = filterByLevel(parseLog(logLines()), 'ERROR');

for (const error of errors) {
    console.log(error.timestamp + ': ' + error.message);
}
// 2025-01-15T10:31:00: Database connection timeout
// 2025-01-15T10:32:00: File not found: /api/data

Iterator Utility Functions

Building a small library of iterator utilities makes generators even more powerful. These composable functions let you build complex data processing pipelines that remain lazy and memory-efficient.

Example: Reusable Iterator Utilities

// Take the first n items
function* take(n, iterable) {
    let count = 0;
    for (const item of iterable) {
        if (count >= n) return;
        yield item;
        count++;
    }
}

// Skip the first n items
function* skip(n, iterable) {
    let count = 0;
    for (const item of iterable) {
        if (count >= n) {
            yield item;
        }
        count++;
    }
}

// Zip multiple iterables together
function* zip(...iterables) {
    const iterators = iterables.map(function(it) {
        return it[Symbol.iterator]();
    });

    while (true) {
        const results = iterators.map(function(it) {
            return it.next();
        });
        if (results.some(function(r) { return r.done; })) return;
        yield results.map(function(r) { return r.value; });
    }
}

// Enumerate -- yield [index, value] pairs
function* enumerate(iterable, start = 0) {
    let index = start;
    for (const item of iterable) {
        yield [index++, item];
    }
}

// Usage examples
function* naturals() {
    let n = 1;
    while (true) yield n++;
}

// Take 5 items starting from the 10th
const page = [...take(5, skip(9, naturals()))];
console.log(page); // [10, 11, 12, 13, 14]

// Zip arrays together
const names = ['Alice', 'Bob', 'Charlie'];
const scores = [95, 87, 72];
const grades = ['A', 'B', 'C'];

for (const [name, score, grade] of zip(names, scores, grades)) {
    console.log(name + ': ' + score + ' (' + grade + ')');
}
// Alice: 95 (A)
// Bob: 87 (B)
// Charlie: 72 (C)

// Enumerate
for (const [i, name] of enumerate(names, 1)) {
    console.log(i + '. ' + name);
}
// 1. Alice
// 2. Bob
// 3. Charlie

Practice Exercise

Build a complete data processing system using iterators and generators. First, create a Range class that implements [Symbol.iterator] using a generator method and supports start, end, and step parameters. Verify it works with for...of, the spread operator, and destructuring. Second, write an infinite generator called fibonacci() that yields the Fibonacci sequence. Create a take(n, iterable) utility function to safely consume the first n values. Use it to get the first 15 Fibonacci numbers. Third, implement a generator-based lazy pipeline with map, filter, and reduce functions that accept generators as input. Use the pipeline to take the first 20 natural numbers, square them, filter those divisible by three, and compute their sum. Fourth, create an async generator called paginatedData() that simulates fetching pages of data from an API. Each page should return five items and there should be four pages total. Use for await...of to iterate over all items. Fifth, write a flatten() generator that recursively flattens deeply nested arrays using yield* delegation. Test it with an array nested four levels deep. Finally, create a tree data structure and implement both depth-first and breadth-first traversal using generators.