Understanding TypeScript generators

mangelosanto

Matt Angelosanto

Posted on June 29, 2023

Understanding TypeScript generators

Written by Debjyoti Banerjee✏️

Normal functions run from top to bottom and then exit. Generator functions also run from top to bottom, but they can be paused during execution and resumed later from the same point. This goes on until the end of the process, and then they exit. In this article, we’ll learn how to use generator functions in TypeScript, covering a few different examples and use cases. Let’s get started!

Jump ahead:

Creating a generator function in TypeScript

Normal functions are eager, whereas generators are lazy, meaning they can be asked to execute at a later point in time. To create a generator function, we‘ll use the function * command. Generator functions look like normal functions, but they behave a little differently. Take a look at the following example:

function normalFunction() {
  console.log("This is a normal function");
}
function* generatorFunction() {
  console.log("This is a generator function");
}

normalFunction(); // "This is a normal function"
generatorFunction();
Enter fullscreen mode Exit fullscreen mode

Although it is written and executed just like a normal function, when generatorFunction is called, we don’t get any logs in the console. Put simply, calling the generator won’t execute the code: Calling Generator Fails Code Execution Example You’ll notice that the generator function returns a Generator type; we’ll look at this in detail in the next section. To make the generator execute our code, we‘ll do the following:

function* generatorFunction() {
  console.log("This is a generator function");
}

const a = generatorFunction();
a.next();
Enter fullscreen mode Exit fullscreen mode

Notice that the next method returns an IteratorResult. So, if we were to return a number from generatorFunction, we would access the value as follows:

function* generatorFunction() {
  console.log("This is a generator function");
  return 3;
}
const a = generatorFunction();
const b = a.next();
console.log(b); // {"value": 3, "done": true}
console.log(b.value); // 3
Enter fullscreen mode Exit fullscreen mode

The generator interface extends Iterator, which allows us to call next. It also has the [Symbol.iterator] property,  making it an iterable.

Understanding JavaScript iterables and iterators

Iterable objects are objects that can be iterated over with for..of. They must implement the Symbol.iterator method; for example, arrays in JavaScript are built-in iterables, so they must have an iterator:

const a = [1,2,3,4];
const it: Iterator<number> = a[Symbol.iterator]();
while (true) {
   let next = it.next()
   if (!next.done) {
       console.log(next.value)
   } else {
      break;
   }
}
Enter fullscreen mode Exit fullscreen mode

The iterator makes it possible to iterate the iterable. Take a look at the following code, which is a very simple implementation of an iterator:

>function naturalNumbers() {
  let n = 0;
  return {
    next: function() {
      n += 1;
      return {value:n, done:false};
    }
  };
}

const iterable = naturalNumbers();
iterable.next().value; // 1
iterable.next().value; // 2
iterable.next().value; // 3
iterable.next().value; // 4
Enter fullscreen mode Exit fullscreen mode

As mentioned above, an iterable is an object that has the Symbol.iterator property. So, if we were to assign a function that returns the next() function, like in the example above, our object would become a JavaScript iterable. We could then iterate over it using the for..of syntax.

Obviously, there is a similarity between the generator function we saw earlier and the example above. In fact, since generators compute one value at a time, we can easily use generators to implement iterators.

Working with generators in TypeScript

The exciting thing about generators is that you can pause execution using the yield statement, which we didn’t do in our previous example. When next is called, the generator executes code synchronously until a yield is encountered, at which point it pauses the execution. If next is called again, it will resume execution from where it was paused. Let’s look at an example:

function* iterator() {
  yield 1
  yield 2
  yield 3
}
for(let x of iterator()) {
  console.log(x)
}
Enter fullscreen mode Exit fullscreen mode

yield basically allows us to return multiple times from the function. In addition, an array will never be created in memory, allowing us to create infinite sequences in a very memory efficient manner. The following example will generate infinite even numbers:

function* evenNumbers() {
  let n = 0;
  while(true) {
    yield n += 2;
  }
}
const gen = evenNumbers();
console.log(gen.next().value); //2
console.log(gen.next().value); //4
console.log(gen.next().value); //6
console.log(gen.next().value); //8
console.log(gen.next().value); //10
Enter fullscreen mode Exit fullscreen mode

We can also modify the example above so that it takes a parameter and yields even numbers, starting from the number provided:

function* evenNumbers(start: number) {
  let n = start;
  while(true) {
    if (start === 0) {
      yield n += 2;
    } else {
      yield n;
      n += 2;
    }
  }
}
const gen = evenNumbers(6);
console.log(gen.next().value); //6
console.log(gen.next().value); //8
console.log(gen.next().value); //10
console.log(gen.next().value); //12
console.log(gen.next().value); //14
Enter fullscreen mode Exit fullscreen mode

Use cases for TypeScript generators

Generators provide a powerful mechanism for controlling the flow of data and creating flexible, efficient, and readable code in TypeScript. Their ability to produce values on-demand, handle asynchronous operations, and create custom iteration logic makes them a valuable tool in a few scenarios.

Calculate values on demand

You can implement generators to calculate and yield values on-demand, caching intermediate results to improve performance. This technique is useful when dealing with expensive computations or delaying the execution of certain operations until they are actually needed. Let’s consider the following example:

function* calculateFibonacci(): Generator<number> {
  let prev = 0;
  let curr = 1;

  yield prev;
  yield curr; 

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

// Using the generator to calculate Fibonacci numbers lazily
const fibonacciGenerator = calculateFibonacci();

// Calculate the first 10 Fibonacci numbers
for (let i = 0; i < 10; i++) {
  console.log(fibonacciGenerator.next().value);
  // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}
Enter fullscreen mode Exit fullscreen mode

In the example above, instead of computing all Fibonacci numbers up front, only the required Fibonacci numbers are calculated and yielded as they are requested. This results in more efficient memory usage and on-demand calculation of values as needed.

Iterate over large data sets

Generators allow you to iterate over large data sets without loading all the data into memory at once. Instead, you can generate values as needed, thereby improving memory efficiency. This is particularly useful when working with large databases or files:

function* iterateLargeData(): Generator<number> {
  const data = Array.from({ length: 1000000 }, (_, index) => index + 1);

  for (const item of data) {
    yield item;
  }
}

// Using the generator to iterate over the large data set
const dataGenerator = iterateLargeData();

for (const item of dataGenerator) {
  console.log(item);
  // Perform operations on each item without loading all data into memory
}
Enter fullscreen mode Exit fullscreen mode

In this example, the iterateLargeData generator function simulates a large data set by creating an array of one million numbers. Instead of returning the entire array at once, the generator yields each item one at a time using the yield keyword. Therefore, you can iterate over the data set without loading all the numbers into memory simultaneously.

Using generators recursively

The memory efficient properties of generators can be put to use for something more useful, like reading file names inside a directory recursively. In fact, recursively traversing nested structures is what comes naturally to me when thinking about generators.

Since yield is an expression, yield* can be used to delegate to another iterable object, as shown in the following example:

function* readFilesRecursive(dir: string): Generator<string> {
  const files = fs.readdirSync(dir, { withFileTypes: true });

  for (const file of files) {
    if (file.isDirectory()) {
      yield* readFilesRecursive(path.join(dir, file.name));
    } else {
      yield path.join(dir, file.name);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can use our function as follows:

for (const file of readFilesRecursive('/path/to/directory')) {
  console.log(file);
}
Enter fullscreen mode Exit fullscreen mode

We can also use yield to pass a value to the generator. Take a look at the following example:

function* sumNaturalNumbers(): Generator<number, any, number> {
    let value = 1;
    while(true) {
        const input = yield value;
        value += input;
    }
}
const it = sumNaturalNumbers();
it.next();
console.log(it.next(2).value); //3
console.log(it.next(3).value); //6
console.log(it.next(4).value); //10
console.log(it.next(5).value); //15
Enter fullscreen mode Exit fullscreen mode

When next(2) is called, input is assigned the value 2; similarly, when next(3) is called, input is assigned the value 3.

Error handling

Exception handling and controlling the flow of execution is an important concept to discuss if you want to work with generators. Generators basically look like normal functions, so the syntax is the same.

When a generator encounters an error, it can throw an exception using the throw keyword. This exception can be caught and handled using a try...catch block within the generator function or outside when consuming the generator:

function* generateValues(): Generator<number, void, string> {
  try {
    yield 1;
    yield 2;
    throw new Error('Something went wrong');
    yield 3; // This won't be reached
  } catch (error) {
    console.log("Error caught");
    yield* handleError(error); // Handle the error and continue
  }
}

function* handleError(error: Error): Generator<number, void, string> {
  yield 0; // Continue with a default value
  yield* generateFallbackValues(); // Yield fallback values
  throw `Error handled: ${error.message}`; // Throw a new error or rethrow the existing one
}

const generator = generateValues();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // Error caught 
                               // { value: 0, done: false }
console.log(generator.next()); // { value: 4, done: false }
console.log(generator.next()); // Error handled: Something went wrong
Enter fullscreen mode Exit fullscreen mode

In this example, the generateValues generator function throws an error after yielding the value 2. The catch block within the generator catches the error, and the control is transferred to the handleError generator function, which yields fallback values. Finally, the handleError function throws a new error or re-throws the existing one.

When consuming the generator, you can catch the thrown errors using a try...catch block as well:

const generator = generateValues();

try {
  console.log(generator.next());
  console.log(generator.next());
  console.log(generator.next());
} catch (error) {
  console.error('Caught error:', error);
}
Enter fullscreen mode Exit fullscreen mode

In this case, the error will be caught by the catch block, and you can handle it accordingly.

Conclusion

In this article, we learned how to use generators in TypeScript, reviewing their syntax and foundation in JavaScript iterators and iterables. We also learned how to use TypeScript generators recursively and handle errors using generators.

You can use generators for a lot of interesting purposes, like generating unique IDs, generating prime numbers, or implementing stream-based algorithms. You can control the termination of the sequence using a condition or by manually breaking out of the generator. I hope you enjoyed this article, and be sure to leave a comment if you have any questions.


Get set up with LogRocket's modern Typescript error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not server-side.

npm:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on June 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related