Skip to main content

TypeScript Generators

Introduction

Generators are a powerful feature in TypeScript that allow you to write functions that can be paused and resumed during execution. They provide an elegant way to create iterators, work with infinite sequences, and implement lazy evaluation patterns.

Introduced in ES2015 (ES6) and fully supported in TypeScript, generators can significantly simplify code that would otherwise require complex state management. In this tutorial, we'll dive deep into TypeScript generators, exploring how they work, when to use them, and examining practical real-world examples.

What Are Generators?

Generators are special functions that can be paused during execution using the yield keyword and later resumed from where they left off. Unlike regular functions that run to completion once called, generators can produce a sequence of values over time.

A generator function:

  • Is defined using the function* syntax (note the asterisk)
  • Uses the yield keyword to pause execution and return a value
  • Maintains its internal state between calls
  • Returns a generator object that conforms to the Iterator protocol

Basic Syntax of TypeScript Generators

Let's start with a simple generator function:

typescript
function* simpleGenerator(): Generator<number> {
console.log('Start execution');
yield 1;
console.log('After first yield');
yield 2;
console.log('After second yield');
yield 3;
console.log('End of generator');
}

// Using the 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:

Start execution
{ value: 1, done: false }
After first yield
{ value: 2, done: false }
After second yield
{ value: 3, done: false }
End of generator
{ value: undefined, done: true }

In this example, calling simpleGenerator() doesn't execute the function body immediately. Instead, it returns a generator object that implements the Iterator interface. Each time we call next() on the generator object, the function executes until it encounters a yield statement, then pauses and returns the yielded value.

Generator Return Type

In TypeScript, you can specify what types your generator will yield using the Generator<T, TReturn, TNext> interface:

  • T: The type of values yielded by the generator
  • TReturn: The type of the value returned when the generator is done (optional)
  • TNext: The type of values that can be passed to the generator via next() (optional)
typescript
// Generator yielding numbers
function* numberGenerator(): Generator<number> {
yield 1;
yield 2;
yield 3;
}

// Generator with custom return type
function* generatorWithReturn(): Generator<number, string, undefined> {
yield 1;
yield 2;
return "Done!"; // Return value when complete
}

const gen = generatorWithReturn();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: "Done!", done: true }

Iterating Over Generators

Since generators implement the Iterator protocol, you can use them with for...of loops, spread operators, destructuring, and other iteration tools:

typescript
function* countToFive(): Generator<number> {
for (let i = 1; i <= 5; i++) {
yield i;
}
}

// Using for...of
console.log('Using for...of:');
for (const num of countToFive()) {
console.log(num);
}

// Using spread operator
console.log('Using spread operator:');
const numbers = [...countToFive()];
console.log(numbers); // [1, 2, 3, 4, 5]

// Using destructuring
console.log('Using destructuring:');
const [first, second, ...rest] = countToFive();
console.log(first, second, rest); // 1 2 [3, 4, 5]

Output:

Using for...of:
1
2
3
4
5
Using spread operator:
[1, 2, 3, 4, 5]
Using destructuring:
1 2 [3, 4, 5]

Infinite Sequences with Generators

One of the most powerful aspects of generators is their ability to represent infinite sequences without consuming infinite memory. The generator only computes values as they're requested:

typescript
function* infiniteCounter(): Generator<number> {
let i = 0;
while (true) {
yield i++;
}
}

// Using the infinite generator (safely)
const counter = infiniteCounter();
console.log(counter.next().value); // 0
console.log(counter.next().value); // 1
console.log(counter.next().value); // 2

// Taking just what we need from an infinite sequence
function take<T>(iterator: Iterator<T>, count: number): T[] {
const result: T[] = [];
for (let i = 0; i < count; i++) {
const next = iterator.next();
if (next.done) break;
result.push(next.value);
}
return result;
}

// Get the first 5 even numbers
function* evenNumbers(): Generator<number> {
let num = 0;
while (true) {
yield num;
num += 2;
}
}

console.log(take(evenNumbers(), 5)); // [0, 2, 4, 6, 8]

Sending Values to Generators

Generators can also receive values from the outside using the next(value) method. The value passed to next() will become the result of the current yield expression:

typescript
function* twoWayGenerator(): Generator<number, void, string> {
const greeting = yield 1; // receives input via next()
console.log(`Received: ${greeting}`);

const name = yield 2; // receives second input
console.log(`Hello ${name}!`);
}

const twoWay = twoWayGenerator();
console.log(twoWay.next().value); // 1 (first yield)
console.log(twoWay.next('hi').value); // logs "Received: hi", returns 2
twoWay.next('TypeScript'); // logs "Hello TypeScript!"

Output:

1
Received: hi
2
Hello TypeScript!

Note that the first next() call is special and any value passed to it is ignored, as there is no yield actively waiting for a value.

Error Handling in Generators

Generators provide the throw() method to inject exceptions into the generator, which can be caught inside the generator using try/catch:

typescript
function* errorHandlingGenerator(): Generator<number> {
try {
yield 1;
yield 2;
yield 3;
} catch (e) {
console.log(`Caught error: ${e.message}`);
yield -1; // Error recovery value
}
yield 4;
}

const errorGen = errorHandlingGenerator();
console.log(errorGen.next().value); // 1
console.log(errorGen.throw(new Error('Something went wrong')).value); // -1 (after logging error)
console.log(errorGen.next().value); // 4

Output:

1
Caught error: Something went wrong
-1
4

Practical Applications of TypeScript Generators

Example 1: Pagination with Generators

Generators are excellent for handling paginated API calls, allowing you to work with large data sets efficiently:

typescript
// Simulating an API that returns paginated data
async function fetchPage(page: number): Promise<{data: number[], hasMore: boolean}> {
// In a real app, this would be a fetch call
await new Promise(resolve => setTimeout(resolve, 100)); // Simulated delay

const pageSize = 3;
const start = page * pageSize;
const end = start + pageSize;
const hasMore = end < 15; // We have 15 items total in this example

return {
data: Array.from({length: pageSize}, (_, i) => start + i),
hasMore
};
}

// Generator to provide seamless iteration over paginated data
async function* paginatedData(): AsyncGenerator<number> {
let currentPage = 0;
let hasMore = true;

while (hasMore) {
const response = await fetchPage(currentPage);

// Yield each item individually
for (const item of response.data) {
yield item;
}

hasMore = response.hasMore;
currentPage++;
}
}

// Using the paginated data generator
async function main() {
console.log("Fetching data page by page:");
for await (const item of paginatedData()) {
console.log(`Item: ${item}`);
}
}

main().catch(console.error);

Example 2: Processing Large Files with Generators

Generators are ideal for processing large files line by line without loading everything into memory:

typescript
// Simulating reading a large file line by line
function* readLinesFromFile(fileContent: string): Generator<string> {
const lines = fileContent.split('\n');

for (const line of lines) {
yield line;
}
}

// Process file without loading it all into memory
function processLargeFile(fileContent: string) {
const lines = readLinesFromFile(fileContent);
let lineCount = 0;
let charCount = 0;

for (const line of lines) {
lineCount++;
charCount += line.length;

// Process only first few lines for this example
if (lineCount <= 3) {
console.log(`Line ${lineCount}: ${line.substring(0, 20)}${line.length > 20 ? '...' : ''}`);
}
}

console.log(`Processed ${lineCount} lines containing ${charCount} characters`);
}

const sampleFileContent = `This is line 1 of a sample file.
This is line 2 with different content.
Line 3 is a bit shorter.
Line 4 has some more text.
And finally, this is line 5.`;

processLargeFile(sampleFileContent);

Output:

Line 1: This is line 1 of a s...
Line 2: This is line 2 with d...
Line 3: Line 3 is a bit short...
Processed 5 lines containing 151 characters

Example 3: State Management with Generators

Generators can be used to implement state machines elegantly:

typescript
type TrafficLightState = 'green' | 'yellow' | 'red';

function* trafficLightFSM(): Generator<TrafficLightState> {
while (true) {
yield 'green';
yield 'yellow';
yield 'red';
}
}

// Using the traffic light FSM
function simulateTrafficLight() {
const light = trafficLightFSM();

// Simulate 6 state changes
for (let i = 0; i < 6; i++) {
const state = light.next().value;
console.log(`Traffic light is now ${state}`);
}
}

simulateTrafficLight();

Output:

Traffic light is now green
Traffic light is now yellow
Traffic light is now red
Traffic light is now green
Traffic light is now yellow
Traffic light is now red

Composing Generators

Generators can be composed and delegated to other generators using the yield* expression:

typescript
function* generateRange(start: number, end: number): Generator<number> {
for (let i = start; i <= end; i++) {
yield i;
}
}

function* generateAlphabet(): Generator<string> {
for (let i = 0; i < 26; i++) {
yield String.fromCharCode(97 + i); // 'a' to 'z'
}
}

function* combinedGenerator(): Generator<number | string> {
// Delegate to other generators
yield* generateRange(1, 5);
yield* generateAlphabet();
}

// Using the combined generator
const combined = combinedGenerator();
for (let i = 0; i < 10; i++) {
console.log(combined.next().value);
}

Output:

1
2
3
4
5
a
b
c
d
e

Generator Performance Considerations

Generators are excellent for working with large or infinite sequences, but there are performance considerations to keep in mind:

typescript
// Performance comparison example
function timeExecution(fn: () => void, label: string) {
const start = performance.now();
fn();
const end = performance.now();
console.log(`${label}: ${(end - start).toFixed(2)}ms`);
}

// Using array - eager evaluation
function getSumWithArray(max: number): number {
const numbers = Array.from({length: max}, (_, i) => i + 1);
return numbers.filter(n => n % 2 === 0).reduce((sum, n) => sum + n, 0);
}

// Using generator - lazy evaluation
function getSumWithGenerator(max: number): number {
function* numbers(max: number): Generator<number> {
for (let i = 1; i <= max; i++) {
if (i % 2 === 0) yield i;
}
}

let sum = 0;
for (const n of numbers(max)) {
sum += n;
}
return sum;
}

const MAX = 10000000;

timeExecution(() => {
const sum = getSumWithGenerator(MAX);
console.log(`Sum (generator): ${sum}`);
}, "Generator approach");

timeExecution(() => {
const sum = getSumWithArray(MAX);
console.log(`Sum (array): ${sum}`);
}, "Array approach");

In the example above, the generator approach is more memory-efficient for large values of MAX because it doesn't need to store the entire array in memory.

When to Use Generators

Generators are particularly useful in scenarios like:

  1. Working with large or infinite sequences
  2. Processing data streams (like file reading)
  3. Implementing iterators without complex state management
  4. Building data pipelines with lazy evaluation
  5. Creating asynchronous workflows (with async generators)
  6. State machines and UI interaction handling

Summary

TypeScript generators provide an elegant way to work with sequences of data, both finite and infinite. They excel at scenarios requiring lazy evaluation, memory efficiency, and complex iteration patterns. Key takeaways include:

  • Generators allow functions to pause and resume using the yield keyword
  • They're defined using the function* syntax and provide the Iterator interface
  • TypeScript adds type safety to generators with the Generator<T, TReturn, TNext> interface
  • Generators are ideal for pagination, file processing, state machines, and more
  • Composition with yield* allows building complex generators from simpler ones
  • Generators provide better memory efficiency through lazy evaluation

Exercises

  1. Create a Fibonacci number generator that yields the Fibonacci sequence
  2. Implement a generator that yields prime numbers
  3. Build a pagination system using generators to fetch data from an API
  4. Create a generator that traverses a tree structure using depth-first search
  5. Build a generator-based state machine for a simple game character with different states (idle, walking, running, jumping)

Additional Resources

By mastering generators, you'll have a powerful tool in your TypeScript arsenal for handling complex iteration patterns with clean, maintainable code.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)