TypeScript Iterators, Generator Functions and Infinite Stream of Data
Posted on: 2023-04-07
We discussed about TypeScript Iterator and Iterable in a recent blog article. Today, we introduce generator functions that are a step further into iterable behavior in TypeScript.
What is a TypeScript Generator Function?
Similar to a normal TypeScript function, a generator returns a value. However, a generator can return many values. A generator function is a function that returns a non-deterministic amount of values until the function calls the known return
keyword with the final value that will terminate the iterator.
What is the Goal of TypeScript Generator Functions?
The goal is to provide a simpler syntax to the language by providing a mechanism for transmitting data without knowing how to navigate the data stream or data structure. The goal perfectly matches the iterable concept where we are not limited to iterating data linearly from one index to another.
What is the Similitude with TypeScript Iterable?
The main similitude is that the TypeScript Generator Functions rely on an object with a next
function.
Before elaborating on TypeScript Generator, let's define a simple example. In the TypeScript Iterator and Iterable article, the first example was to iterate on a random amount of data.
const myIterable: Iterable<number> = {
[Symbol.iterator]: () => {
let index = 0;
const end = 3 + Math.random() * 7; // Max will be 10
const myIterator: Iterator<number> = {
next: () => {
if (index >= end) {
return {
value: null,
done: true,
};
} else {
index++;
return {
value: index - 1, // Value is [0 to 9]
done: index >= end,
};
}
},
};
return myIterator;
},
};
for (const c of myIterable) {
console.log(`Value is: ${c}`);
}
Let's migrate this example with a TypeScript generator function.
function* myGenerator() {
let index = 0;
const end = 3 + Math.random() * 7; // Max will be 10
while (index < end) {
// Smaller here because we will return that last value with return
yield index;
index++;
}
return index;
}
for (const c of myGenerator()) {
console.log(`Value is: ${c}`);
}
Full code in CodeSandBox.
A few details in this generator function:
- The function has a
*
after the name. It marks the function has a generator. - The function can use
yield
to return value without stopping the function - There is a bug in the code above!
The bug in the function above is that the last index is never displayed by the for of
loop. So let's change the code to clarify the bug.
// Generator Function
function* myGenerator() {
let index = 0;
const end = 5;
while (index < end) {
// Smaller here because we will return that last value with a return
yield index;
index++;
}
return index;
}
console.log("Automatically");
for (const c of myGenerator()) {
console.log(`Value is: ${c}`);
}
console.log("Manually");
const g = myGenerator();
console.log(g.next().value); // 0
console.log(g.next().value); // 1
console.log(g.next().value); // 2
console.log(g.next().value); // 3
console.log(g.next().value); // 4
console.log(g.next().value); // 5
console.log(g.next().value); // undefined
The first time, the code prints with the loop. The console shows:
Automatically
Value is: 0
Value is: 1
Value is: 2
Value is: 3
Value is: 4
The second part shows that we manually call the next().value
. The output shows the value 5
.
Manually
0
1
2
3
4
5
Injection of Values inside the Generator Function
In the TypeScript Iterator and Iterable previous article, we discovered that a sibling function to next
, named return
, allows to inject data back into the iterator. Similarly, the generator function has a mechanism to inject value inside the iterator. However, it is with the yield
function this time. The following code demonstrates that we can alter the previous example and provide a value to the next
. In this modification, when the next
has a value, it is used as the incrementor instead of incrementing of 1
.
function* myGenerator() {
let index = 0;
const end = 8;
while (index < end) {
// Smaller here because we will return that last value with return
const increment = yield index;
index += increment ?? 1; // When not provided, return undefined. Using ?? change undefined to 1.
}
return index;
}
const g = myGenerator();
console.log(g.next().value); // 0
console.log(g.next().value); // 1
console.log(g.next(2).value); // 3
console.log(g.next(3).value); // 6
console.log(g.next().value); // 7
console.log(g.next().value); // 8
Because we are passing 2
instead of doing 1 + 1
, it does 1 + 2
. Thus, the value returned is 3
. Then, the next(3)
tells the code to do index += 3
which makes the value 3+3 to be 6
. You can see the code in CodesandBox
Injecting an Error Inside the Generator Function
Similarly to injecting value inside the generator function, it is possible has the consumer to notify the function that something is wrong by calling the throw
function. Again, the goal is to inject logic when something goes wrong. For example, if your server crashes you should close the connection to the source that is yielding results.
Here is a simple example: if there is an exception, the index returns to zero.
function* myGenerator() {
let index = 0;
const end = 8;
while (index < end) {
// Smaller here because we will return that last value with a return
try {
const increment = yield index;
index += increment ?? 1;
} catch (e) {
index = 0;
}
}
return index;
}
const g = myGenerator();
console.log(g.next().value); // 0
console.log(g.next().value); // 1
console.log(g.next(2).value); // 3
console.log(g.next(3).value); // 6
g.throw("Error here");
console.log(g.next().value); // 1
console.log(g.next().value); // 2
A try
and catch
block must surround the yield
code inside the function generator. When the throw
function is called, the execution of the generator function goes into the catch
. In this example, we reset the index to zero. Full code in CodeSandbox.
Completing a Generator Function
In many scenarios, looping the iterable content will naturally end by having the generator function stop yielding and returning. However, in many other scenarios, there is no official ending. It loops (literally loop) forever, waiting for events to happen. As the consumer of that continuous function, we may want to close the data stream. The way to do it is to call the return
function. Calling the return
function sets the internal iterator to done: true
, meaning that any potential access to the iterator would result in a terminated iterator. The only way to start again would be to create a new iterator.
Note that if the generator function never returns and does not have a looping mechanism that one the last yield
the function will complete as the next time the consumer calls the next
, it would resolve into a done: true
with an undefined value.
Generator Function Usage
Generator functions are rarely used. Use cases of streaming data require a use case where you have your own data structure or are building a streaming data mechanism. In both cases, most developers would consume a tool that acts rather than creating one. As a result, few frameworks leverage the mechanism like Redux Saga. However, because most people are unfamiliar, frameworks that leverage the mechanism start one step behind as the learning curve increases from the get-go. Another reason the adoption of generator functions is not mainstream is that it was officially supported only with ECMAScript 6.
Using an iterable/iterator approach or the generator function is a matter of preference. The generator function offers a syntactic shortcut to achieve the same goal. With the generator function, you avoid having to worry about Symbol
and define up to three functions (next, throw, return) and work with the object that has the value and state of the iterator.
Performance
A glance suggests that an infinite data stream may cause the system to fall into a stack overflow or take all the computer resources. However, generator functions are lazily evaluated. A lazily evaluated code means that the execution of the code only happens when the code is actively invoked. In the case of the generator function, the code is only executed when someone calls next
. Indeed, if we are running inside a for of
loop, it would result in some performance issues as the code will continually call the yield
. However, the following code is fine if the consumer calls it at a reasonable pace.
function* myGenerator() {
let index = 0;
while (true) {
yield index;
index++;
}
}
The reason is that the loop goes into an iteration only when the consumer call the next
const g = myGenerator();
console.log(g.next().value); // 0
console.log(g.next().value); // 1
Thus, the code is looping twice and not infinitely. Using the same generator function, we can loop for ten milliseconds and see hundreds of iteration before the generator stop yielding and wait for a future call.
const g = myGenerator();
const t = Date.now();
while (Date.now() - t < 10) {
console.log(g.next().value);
}
// We can also close the iteration
console.log(g.return()); // {value:undefined, done:true}
console.log(g.next().value); // Undefined
Code in CodeSandbox
Conclusion
TypeScript has supported generator functions for many months, and as the standard goes up in stage are more widely available to use as they are integrated natively into browsers. Generator functions are a tool in the developer's toolbox to be aware of and can be helpful in case you are working with a stream of data or process-intensive tasks that can be broken down into sub-tasks, which the consumer can call at a wise pace.