Understanding TypeScript generators

Understanding TypeScript Generators

Generator functions run from top to bottom like normal functions but can be paused during execution and resumed later from the same point. This process continues until the end of the function, 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!

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 use the function * command. Generator functions look like normal functions, but they behave a little differently. Here’s an example:

function* generatorFunction() {
  console.log('First');
  yield;
  console.log('Second');
  yield;
  console.log('Third');
}

const generator = generatorFunction();

generator.next(); // First
generator.next(); // Second
generator.next(); // Third

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();

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

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;
   }
}

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

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)
}

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

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

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
}

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
}

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);
    }
  }
}

We can use our function as follows:

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

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

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

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);
}

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

Conclusion

In the 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.

Generators can be used 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.

No comments:

Post a Comment