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:
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