Javascript generators are under-appreciated, yet they are actually very useful and powerful, in this article, we will explore the basics and the power of javascript generators.
Introduction
Javascript Generator is a regular function that does return Generator
object.
Javascript Generator works well and conforms with iterable protocol and the iterator protocol, which allows for the creation of data streams with ease.
Generator functions
Syntax
The generator function is identified by having the (asterisk/star *
) character next to the function keyword.
It can be written as the following:
function* f(…){}
orfunction *f(…){}
Both syntaxes are correct.
But usually, the first syntax is preferred, as the asterisk/star *
denotes that it’s a generator function, it describes the kind, not the name, so it should stick with the function
keyword.
How do JS generators work?
To create a generator function, we need a special syntax construct: function*
, so-called “generator function”.
It looks like the following:
function* alphabitGenerator() {
yield 'a';
yield 'b';
yield 'c';
return 'd';
}
The Generator constructor type isn’t available globally, it only can be returned from a generator function. As shown in the example below.
function* alphabitGenerator() {
yield 'a';
yield 'b';
yield 'c';
return 'd';
}
const generator = alphabitGenerator();
// call generator without parentheses
// generator
// Expected output: generator {<suspended>}
Generator functions behave differently from regular functions, you can think of generators as start/stop functions. Meaning once the generator function is called it won’t execute its all code at once, rather it will execute until the nearest yield <value>
statement. And the return type would be of Generator type.
The main methods of the javascript generator function are:
next()
: The result of thenext()
method call is always an object with two properties:value
: the yielded/returned value.done
:true
if the function code has finished, otherwisefalse
.
For example, calling the next method on the above example would result in the following for the first call, and the code execution will stop at line 3 ” yield 'b'
“
const generator = alphabitGenerator();
generator.next();
// Expected output: {value: 'a', done: false}
Calling generator.next()
for the second time, the code execution will resume and the following output will result:
const generator = alphabitGenerator();
generator.next(); // {value: 'a', done: false}
generator.next(); // {value: 'b', done: false}
The same would happen when we call the function for the 3rd time:
const generator = alphabitGenerator();
generator.next(); // {value: 'a', done: false}
generator.next(); // {value: 'b', done: false}
generator.next(); // {value: 'c', done: false}
Calling it for the 4th time, the execution will reach the return statement and the done
property value will be true
const generator = alphabitGenerator();
generator.next(); // {value: 'a', done: false}
generator.next(); // {value: 'b', done: false}
generator.next(); // {value: 'c', done: false}
generator.next(); // {value: 'd', done: true}
Now the generator is done. We should see it from done:true
and process value:'d'
as the final result.
Calling the generator.next()
again and again, would always result with value=undefined
and done=true
.
To recap:
.next()
advances.
yield
pauses.
return
stops.
Generators are also iterable
We can loop over the generator values using for...of
loop.
function* alphabitGenerator() {
yield "a";
yield "b";
yield "c";
return "d";
}
const generator = alphabitGenerator();
for (let value of generator) {
console.log(value.toUpperCase());
}
// Expected Output:
// A
// B
// C
Note that the D letter wasn’t returned/printed, this is because for...of
ignores the last value when done=true
.
So, if we want all results to be shown by for..of
, we must return them with yield
:
function* alphabitGenerator() {
yield "a";
yield "b";
yield "c";
yield "d";
}
const generator = alphabitGenerator();
for (let value of generator) {
console.log(value.toUpperCase());
}
// Expected Output:
// A
// B
// C
// D
As well since the generators are iterable we can apply all iterable protocol related functionalities, such as the spread syntax ...
function* alphabitGenerator() {
yield "a";
yield "b";
yield "c";
yield "d";
}
const generator = alphabitGenerator();
console.log([...generator])
// Expected Output:
// (4) ['a', 'b', 'c', 'd']
Practical uses of generators
1- Custom iterables
For example, we can convert a custom object list/map into an iterable using generators, such as the following:
const letters = {
letter1: "a",
letter2: "b",
letter3: "c",
[Symbol.iterator]: function* () {
let index = 1;
for (index; index <= Object.keys(this).length; index++) {
yield this[`letter${index}`];
}
},
};
for (let letter of letters) {
console.log(letter.toUpperCase());
}
// Expected Output:
// A
// B
// C
In the example above we’ve added a custom iterable functionality to the object which allowed us to use for...of
loop, to loop over the elements of the object and print them in a capital case.
Another example for custom iterables, let’s build a functionality that allows us to generate numbers in the range from-to
.
let range = {
from: 1,
to: 5,
[Symbol.iterator]: function*() {
for(let value = this.from; value <= this.to; value++) {
yield value;
}
}
};
console.log( [...range] ); // 1,2,3,4,5
In the above example, we’ve created an object with two properties from
and to
, then we’ve attached a custom iterable functionality to it, this functionality will run through a loop and would return numbers inclusive in the range from-to
.
2- Async iteration with asyncIterator
The following example demonstrates how we can send an HTTP request using asyncIterator and paginate through the results easily using Javascript generators.
getStarShips = (endpoint) =>
async function*() {
let nextUrl = `https://swapi.dev/api/${endpoint}`;
while (nextUrl) {
const response = await fetch(nextUrl);
const data = await response.json();
nextUrl = data.next;
yield* data.results;
}
}
const starWars = ({
ships: {
[Symbol.asyncIterator]: getSwapiPagerator("starships")
}
});
for await (const ship of starWars.ships) {
console.log(ship.name);
}
// Expected output
CR90 corvette
Star Destroyer
Death Star
Millennium Falcon
Y-wing
X-wing
TIE Advanced x1
Executor
.
.
etc.
Generator composition
Generator composition is a unique feature of generators that allows to transparently “embed/call” generators in each other.
For instance, we have a function that generates a set of random numbers:
function* generateRandoms(count) {
for (let i = 1; i <= count; i++) yield Math.random() * i;
}
The above function would generate a random number on each next()
call, until we get random numbers equal to the count parameter.
Now, let’s re-use the above function to generate a set of random numbers at once without the need to call next()
each time
function* getSetOfRandomNumbers() {
yield* generateRandoms(5)
}
let randoms = [];
for (let number of getSetOfRandomNumbers()) {
randoms.push(number)
}
console.log(randoms);
// Expected output:
(5) [0.9618312667780231, 0.5782118321378436, 0.058907648425587045, 2.373007701444269, 4.299121086412223]
So we’ve created a new function getSetOfRandomNumbers
that calls the generateRandoms
function and waiting for all the yielded values to be done using yield*
.
The yield*
directive delegates the execution to another generator. As in the above example, this means the
yield* generateRandoms(5)
will iterate over the generateRandoms
generator and forward its yielded values to the outer function getSetOfRandomNumbers
.
yield is a two-way street
The Javascript generator yield
is a two-way street, in addition to returning the result to the outside scope, it also can pass the value inside the generator.
To do so, we should call generator.next(input)
, with an input argument. That argument becomes the result of yield
.
For example:
function* question() {
// Pass a question to the outer code and wait for an answer
let result = yield "What's Your Country?";
alert(result);
}
let generator = question();
alert(generator.next().value)
generator.next('US');
- The first call
generator.next()
should be always made without an argument (the argument will be ignored if passed). It starts the execution and returns the result of the firstyield "What's Your Country?"
. At this point, the generator pauses the execution while staying on the same line. - Then, the result of the yield is displayed in an alert box.
- On
generator.next('US')
, the generator resumes, andUS
gets in as the result:let result = 'US'
.
Generator return method
The generator return
method finishes the execution of the generator.
function* alphabitGenerator() {
yield "a";
yield "b";
}
const g = alphabitGenerator();
console.log(g.next()); // { value: 'a', done: false }
console.log(g.return('c')); // { value: 'c', done: true }
console.log(g.next()); // { value: undefined, done: true }
As shown above, once the return method is called, the generator finishes the execution, and if we tried to call the return method again on a completed generator, it will return that same value again.
A good use case of the generator return method is to stop the generator execution based on a specific condition.
Generator throw method
The generator throw method works the same as the generator return method, it acts as if an error is inserted inside the generator body.
For example:
function* question() {
try {
// Pass a question to the outer code and wait for an answer
let result = yield "What's Your Country?"; // 2
// The execution won't reach here, because the exception is thrown above
alert(result); // 3
} catch (e) {
// This will execute
alert(e); // 4
}
}
let generator = question();
alert(generator.next().value)
generator.throw(new Error("Please enter a valid country value")); // 1
In the example above, line (3) won’t be executed, this is because we’ve thrown an exception at line (1) which leads to an exception at the line (2), and line (4) will show the error.
That’s it for Javascript Generators.
Photo from unsplash.