Understanding Generators in JavaScript

Soumyadip Sarkar
5 min readNov 18, 2024

Generators are one of the most powerful yet often underutilized features in JavaScript. Introduced in ECMAScript 2015 (ES6), generators allow you to write functions that can exit and later re-enter, maintaining their context (variable bindings) between re-entries. In this comprehensive guide, we’ll explore what generators are, how they work, and how you can leverage them in your JavaScript and TypeScript projects.

What Are Generators?

At their core, generators are special functions that can pause execution and resume at a later point. This is made possible through the use of the yield keyword. When a generator function yields, it pauses its execution and returns a value. When it's resumed, it picks up right where it left off.

Generators provide a powerful way to work with asynchronous code, manage complex iterables, and implement custom control flows.

Key Characteristics:

  • Lazy Evaluation: Generators compute their yielded values on the fly, which can be more memory-efficient.
  • Pausing and Resuming: Unlike regular functions, generators can pause execution and maintain their state between invocations.
  • Iterable: Generators conform to the iterator protocol, making them easily usable in loops and other iterable contexts.

Understanding the Syntax

Let’s start by looking at how to define a generator function.

Defining a Generator Function

A generator function is declared using the function* syntax:

function* generatorFunction() {
// Generator code here
}

Alternatively, you can use generator expressions:

const generatorFunction = function* () {
// Generator code here
};

In TypeScript, the syntax remains the same, but you can add type annotations:

function* generatorFunction(): IterableIterator<number> {
// Generator code here
}

The yield Keyword

Inside a generator function, you use the yield keyword to pause the function and return a value:

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

Each yield pauses the function, and when the generator is resumed, execution continues from the next line after yield.

Basic Usage of Generators

Let’s dive into a simple example to illustrate how generators work.

Example: Simple Number Generator

function* simpleGenerator() {
console.log('Generator started');
yield 1;
console.log('Yielded 1');
yield 2;
console.log('Yielded 2');
yield 3;
console.log('Generator finished');
}

To use this generator:

const gen = simpleGenerator();

console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }

Output:

Generator started
{ value: 1, done: false }
Yielded 1
{ value: 2, done: false }
Yielded 2
{ value: 3, done: false }
Generator finished
{ value: undefined, done: true }

Explanation

  • First next() call: Starts the generator and runs until the first yield.
  • Subsequent next() calls: Resume execution from after the last yield.
  • Final next() call: After all yield statements are exhausted, the generator returns { value: undefined, done: true }.

Iterators vs. Generators

Before generators were introduced, the iterator protocol was the standard way to create custom iterables.

The Iterator Protocol

An object is an iterator when it implements a next() method that returns an object with { value, done }.

const iterator = {
current: 0,
next() {
this.current++;
if (this.current <= 3) {
return { value: this.current, done: false };
} else {
return { value: undefined, done: true };
}
},
};

console.log(iterator.next()); // { value: 1, done: false }

Generators Simplify Iterators

Generators conform to the iterator protocol but are much easier to implement.

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

Advanced Generator Techniques

Generators are not limited to yielding simple values; they can accept input, throw errors, and even delegate to other generators.

Passing Values to yield

You can pass values back into the generator using the next() method.

function* inputGenerator() {
const num = yield 'Enter a number';
console.log(`You entered: ${num}`);
}

const gen = inputGenerator();
console.log(gen.next()); // { value: 'Enter a number', done: false }
gen.next(42); // Logs: You entered: 42

Error Handling with Generators

You can throw errors inside a generator, and they can be caught outside.

function* errorGenerator() {
try {
yield 1;
yield 2;
yield 3;
} catch (error) {
console.log(`Error caught inside generator: ${error}`);
}
}

const gen = errorGenerator();
console.log(gen.next()); // { value: 1, done: false }
gen.throw(new Error('Something went wrong')); // Error caught inside generator

Delegating Generators with yield*

You can delegate to another generator or iterable using yield*.

function* subGenerator() {
yield 'a';
yield 'b';
}

function* mainGenerator() {
yield 1;
yield* subGenerator();
yield 2;
}

const gen = mainGenerator();

for (const value of gen) {
console.log(value);
}

// Output:
// 1
// 'a'
// 'b'
// 2

Real-World Examples

Generators shine in scenarios where you need complex iteration, lazy evaluation, or to manage asynchronous code.

Example 1: Infinite Sequences

Generators can create infinite sequences without consuming extra memory.

function* infiniteSequence() {
let i = 0;
while (true) {
yield i++;
}
}

const gen = infiniteSequence();

console.log(gen.next().value); // 0
console.log(gen.next().value); // 1
// And so on...

Example 2: Async Programming with Generators

Before async/await was introduced, generators were used to manage asynchronous code flows.

function* asyncFlow() {
const data = yield fetchData();
console.log(data);
}

function fetchData() {
return new Promise((resolve) => setTimeout(() => resolve('Data fetched'), 1000));
}

const gen = asyncFlow();

const promise = gen.next().value;
promise.then((data) => gen.next(data));

Example 3: Traversing Trees or Graphs

Generators can be used to traverse complex data structures like trees.

function* inorderTraversal(node) {
if (node) {
yield* inorderTraversal(node.left);
yield node.value;
yield* inorderTraversal(node.right);
}
}

// Usage
const tree = {
value: 1,
left: { value: 2 },
right: { value: 3 },
};

for (const value of inorderTraversal(tree)) {
console.log(value); // Output: 2, 1, 3
}

Generators in TypeScript

TypeScript provides strong typing for generators, making your code safer and more predictable.

Typing Generator Functions

You can specify the types of values yielded and returned by the generator.

function* typedGenerator(): Generator<number, string, boolean> {
const input: boolean = yield 42; // Yield number, expect boolean input
return input ? 'Yes' : 'No'; // Return string
}
  • First Type Parameter (number): The type of values yielded by yield.
  • Second Type Parameter (string): The type of the value returned by the generator (return statement).
  • Third Type Parameter (boolean): The type of values that can be passed back into the generator via next().

Using IterableIterator

For simple cases, you can use IterableIterator<T>.

function* numberGenerator(): IterableIterator<number> {
yield 1;
yield 2;
yield 3;
}

Conclusion

Generators are a powerful feature that can make your JavaScript and TypeScript code more efficient and expressive. They provide a unique way to handle iteration, manage asynchronous flows, and create complex data processing pipelines.

While they may seem daunting at first, understanding generators can greatly enhance your programming toolkit. Start experimenting with them in your projects, and you’ll soon discover the myriad of ways they can simplify your code.

Did you find this guide helpful? Share your thoughts in the comments below!

--

--

Soumyadip Sarkar
Soumyadip Sarkar

Written by Soumyadip Sarkar

Independent Researcher & Software Engineer

No responses yet