JavaScript Generators - A Beginner's Guide

by Pinta

5 min read

An image of a programmer in deep thought In the world of JavaScript, functions are our workhorses. They take input, perform some magic, and (usually) return an output. But what if you need a function to pause execution mid-way, and then pick up right where it left off, potentially multiple times? That's where generators step in.

A beginner's guide to generators

  • Generators are special functions marked with an asterisk (*).
  • Instead of the return keyword, they use yield to pause execution and return a value.
  • Each time a generator's next() method is called, it resumes from where it left off, until it's finished.

Why use generators?

  1. Efficiently Handling Large or On-Demand Data: Generators excel at handling infinitely generated sequences or processing data in chunks without needing to store entire datasets in memory. This is perfect for scenarios with massive files, real-time data streams, or computationally intensive value generation.

  2. Resource Management and Control: Unlike regular functions, generators enable fine-grained control over when computations happen and when resources are released. This can help optimize pipelines where you need to manage intermediate results without sacrificing memory efficiency.

  3. Cleaner Iterations in Special Cases: While generators are iterable, their true power lies in custom iterators where you want logic inside the iteration itself, rather than iterating over a pre-existing collection. This is particularly useful for building recursive structures or complex sequences.

  4. Creating Coroutines (Advanced): Generators provide a foundation for implementing coroutines. Coroutines are like functions that can pause, yield control to each other, and resume seamlessly. This enables cooperative multitasking – switching between tasks without the complexity of full-blown threads.

A simple example

function* numberGenerator() {
    yield 1;
    yield 2;
    yield 3;
}

const generator = numberGenerator();

console.log(generator.next().value); // Output: 1
console.log(generator.next().value); // Output: 2
console.log(generator.next().value); // Output: 3
console.log(generator.next().value); // Output: undefined (generator is done)

Real-world use case

Imagine fetching posts from a social media API. Instead of trying to load all the posts at once, a generator could:

  1. Fetch a set number of posts and yield them.
  2. Pause and wait for a user's request to see more.
  3. Resume, fetch the next batch, and yield those.

Key takeaways so far

  • Generators introduce the concept of pausing and resuming code execution.
  • They improve memory management and streamline asynchronous flows.
  • If you're dealing with large datasets, asynchronous patterns, or need custom iterators, generators are worth exploring.

More advanced techniques

If you've grasped the basics of JavaScript generators (if not, consider revisiting the idea of yield and next()), it's time to level up! In this post, we'll explore how generators can supercharge your programming with advanced patterns:

1. Two-way communication with yield

  • Remember, yield doesn't only output values. You can send values back into a running generator using generator.next(value).
  • Use case: Creating dialog-like interactions; the external code controls the flow of the generator by feeding data back to it.

Example:

function* questionGenerator() {
    const name = yield "What's your name?";
    const quest = yield `Hi ${name}, what is your quest?`;
    yield `To seek the ${quest}!`;
}

const generator = questionGenerator();

console.log(generator.next().value); // "What's your name?"
console.log(generator.next("Arthur").value); // "Hi Arthur, what is your quest?"
console.log(generator.next("Holy Grail").value); // "To seek the Holy Grail!"

2. Delegating execution with yield*

  • yield* lets you delegate control to another generator (or iterable).
  • Use case: Composing complex generators from smaller, more manageable ones.

Example:

function* numberGenerator() {
    yield 1;
    yield 2;
}

function* alphabetGenerator() {
    yield "a";
    yield "b";
}

function* combinedGenerator() {
    yield* numberGenerator();
    yield* alphabetGenerator();
}

for (const value of combinedGenerator()) {
    console.log(value); // Output:  1, 2, 'a', 'b'
}

3. Error handling within generators

Generators offer flexibility when it comes to error handling. You can use both traditional try...catch blocks and carefully control error propagation using yield and generator.throw():

A. Inside the Generator: try...catch

  • Place potentially error-prone code within a try...catch block just as you would in regular functions.
  • Example:
function* riskyGenerator() {
    try {
        const someData = yield fetchData(); // Assume fetchData() could throw an error
        // Proceed with more logic if no error
    } catch (error) {
        yield `An error occurred: ${error.message}`;
    }
}

B. Outside the Generator: generator.throw()

  • The code consuming a generator can signal an error back into the generator using generator.throw(error).
  • This will cause an exception to be raised at the point of the current yield inside the generator.
  • Example:
const generator = riskyGenerator();
let result = generator.next();

if (!result.value.isValid()) {
    generator.throw(new Error("Invalid data received"));
}

C. Choosing Between yield and throw

  • Yielding Errors:
    • Provides the caller of the generator more control to decide how to handle the error.
    • Gives the potential to recover from the error within the generator, depending on its design.
  • Throwing Errors:
    • Stops execution of the generator immediately.
    • Useful for signaling unrecoverable errors, where the generator cannot meaningfully continue.

Illustrative Example:

function* processFile(filename) {
    // ... file opening and setup ...
    try {
        for await (const line of file) {
            if (isInvalidLine(line)) {
                yield { error: "Invalid Line", lineNumber };
            } else {
                yield processValidLine(line);
            }
        }
    } catch (error) {
        generator.throw(new Error(`Critical file error: ${error.message}`));
    }
}

Explanation: The example above uses a mix:

  • Non-critical line errors are yield-ed, allowing the caller to filter/log them while proceeding.
  • Critical file errors (e.g., permissions, corrupt file) are throw-n, as the generator cannot continue.

Generators give you fine-grained control over how your code responds to errors. Choose the technique that aligns with the desired error handling strategy for your specific use case.

4. Generators and Promises:

async and await provide a beautifully clear way to work with promises and generators:

  • async Functions: When you mark a function as async, it automatically returns a promise. Inside an async function, you can use the await keyword.
  • await Magic: The await keyword pauses the execution of the async function until a promise resolves. It then unwraps the resolved value and lets your code continue as if it were dealing with synchronous code.

Example: Fetching API Data

Let's see how this works with a simplified API fetching example:

async function* fetchPostsPage(pageNumber) {
    const response = await fetch(`https://api.example.com/posts?page=${pageNumber}`);
    const data = await response.json();
    yield data;
}

async function displayPosts() {
    let currentPage = 1;

    while (true) {
        const generator = fetchPostsPage(currentPage);
        const result = await generator.next();
        const posts = result.value;

        if (posts.length === 0) {
            // No more posts
            break;
        }

        // Process and display the fetched posts
        for (const post of posts) {
            // ... display post logic here ...
        }

        currentPage++;
    }
}

Notice how the code within displayPosts reads almost like synchronous code, even though it's dealing with asynchronous operations under the hood!

Error Handling

Error handling within async functions and generators works smoothly with try...catch blocks:

async function* riskyDataFetch() {
    try {
        const response = await fetch("https://nonexistent-api.example.com/data"); // Forced error
        const data = await response.json();
        yield data;
    } catch (error) {
        yield error;
    }
}

async function handleFetch() {
    const generator = riskyDataFetch();
    const result = await generator.next();

    if (result.done) {
        console.error("An error occurred:", result.value);
    } else {
        console.log("Data:", result.value);
    }
}