Photo by Tim Mossholder on Unsplash
Generator Functions: Zero to Hero
Everything about an amazing JavaScript feature you might never use.
Hello and welcome.
Huh? You're going to teach me about something I don't absolutely need to know?
Yes, that's right. Let me explain first, and then you can decide whether to read on or not. But if you stick around, I think you'll enjoy the ride while learning something cool.
The reason most JS developers won't learn about generator functions is simple: you can build the same functionality with just plain functions inside modules. Mostly. But you'll also be missing out on some cooly cool API interoperability, plus some idiomatic language keywords and hints that make your code more reusable and maintainable.
That's about as succinct as I can make the distinction. If it's still not clear, check out my first example.
Example #1: ID generator
Let's pretend my task is to write an ID generator. So I sluff it and write this:
export function getID() {
return Math.random().toString().slice(2)
}
It works, I'm a genius! But then my manager asks me to guarantee that each ID is unique. That means keeping state
, of some kind. OK... So I'll adjust to this:
let id = 1; // note: this state must live OUTSIDE the function
export function getID() {
return String(id++);
}
It's not a perfect solution of course (eg. what if my server resets?, etc). But it illustrates the basic point: a function can keep state
thanks to closures, which provide references to data outside the function's scope. This technique is called a side-effect, which means now the function is no longer a pure function.
Here's how we can write the same functionality, but using a function generator instead:
export function* getID() {
let id = 1;
while(true) {
yield String(id++);
}
}
Interesting, eh?! Let's break down a few important observations. First, by using function*
we unlock another keyword in the language: yield
. (Kinda like async
functions allow us to await
, too.) What yield
means is:
I want you to
yield
(i.e. return) this value to the caller. But when I get called again, START FROM HERE; don't start at the beginning (like a normal function would).
In our generator, it may look like we're stuck in an infinite while
loop. But, thanks to yield
, we never actually get stuck! In fact, this is a very common pattern with generators... but more on that later.
Also, notice that there is no reference to external variables; this isn't a closure, yet it still manages state.
How do we call this thing? Well, it's not quite like a stateless/pure function. Instead, there are two possible ways, and both of them begin by creating an instance of our generator function. (Think of it like a class constructor, but without the new
keyword.) For example:
const myIDGenerator = getID();
This sets up all the initial state we want to be maintained. Now, we can call myIDGenerator
almost like a normal function:
const result = myIDGenerator.next();
console.log(result); // { value: '1', done: false }
Wow, that's kinda weird at first, isn't it? If so, this is your introduction to the iterators API, and you're smarter already. π€ Here's the gist of the API:
You call a generator via its
.next()
property function.Its response is an object like
{value: any, done: boolean}
, which helps you track its lifecycle executionstate
.
For our example, result.done
will never be true
because it's in a while(true)
infinite loop. However, we can keep calling it over and over like this:
console.log(myIDGenerator.next()); // { value: '1', done: false }
console.log(myIDGenerator.next()); // { value: '2', done: false }
console.log(myIDGenerator.next()); // { value: '3', done: false }
Forever! The generator tracks all the state for us.
Or if we wanted value
to start back at 1
, we simply instantiate a new getID()
generator (to init the state), then call its .next()
property function.
There are two more important tricks to know about: for...of
, and injecting
.
For...of loops
For generator functions that are finite (unlike our previous getID
example, which used an infinite while(true)
loop), then we can leverage a special looping syntax that will automatically iterate over all the yielded finite values of the generator. Let's take a look:
function* myNumbers() {
const nums = [1,2,3,4,5]
while(nums.length) {
yield nums.shift()
}
}
for (const n of myNumbers()) {
console.log("number:", n)
}
As you probably expect, this code will log out the numbers 1 through 5, by leveraging the for...of
loop. When our generator's condition check of while(nums.length)
becomes false
, the generator returns a response object of {value: undefined, done: true}
, which then signals to the for...of
loop that, well... it's done!
Advanced Note: If our generator just returned one value, and it was
undefined
, then we still wouldn't know if it was done iterating, would we?? Maybe that's a valid value, but there's still more to iterate over! That's why we need the{value, done}
result API.
What if I want to make my function* myNumbers()
generator more useful by providing it with a custom array, instead of a hard-coded one? That's simple; we can modify it with a param just like it's a constructor function, like this:
function* myNumbers(nums) {
while(nums.length) {
yield nums.shift()
}
}
for (const n of myNumbers([1,2,3,4,5])) {
console.log("number:", n)
}
And we still get the same result of logging 1 through 5.
Injecting A Value For yield
Generators are especially powerful because they do not need to be a "closed system" once instantiated. In fact, you can "seed" any value into the generator whenever calling its .next(x)
function. Let's take a look:
function* myNumbers(nums) {
let plusValue = 0;
while(nums.length) {
plusValue = yield (nums.shift() + plusValue);
}
}
const myNumbersGenerator = myNumbers([1,2,3,4,5]);
console.log(myNumbersGenerator.next(0).value); // 1
console.log(myNumbersGenerator.next(10).value); // 12
console.log(myNumbersGenerator.next(20).value); // 23
console.log(myNumbersGenerator.next(20).value); // 24
console.log(myNumbersGenerator.next(30).value); // 35
console.log(myNumbersGenerator.next(30).value); // undefined
Here, the injected value is assigned to plusValue
and used to "pad" the given number by adding them both together. Of course, you can get very creative and use it for all sorts of purposes, for example resetting an ID generator's value, signaling to early-exit an internal loop, etc.
Advanced: How to Use yield*
But what happens when a generator calls into a child generator? Hmmm. Most often, the parent generator would like to yield everything from the child, until the child returns a done: true
response. To accomplish this elegantly, the parent generator can use the yield* childGenerator
convenience syntax. Here's an example:
function* nums() {
yield 42;
yield 43;
}
function* main() {
yield* nums(); // yield to my child until it's done
yield 'IM DONE TOO';
}
const generator = main();
console.log(generator.next().value); // 42
console.log(generator.next().value); // 43
console.log(generator.next().value); // "IM DONE TOO"
console.log(generator.next().value); // undefined
Advanced: async function*
This feature will practically allow a generator to return a Promise
response, which looks like this example:
async function* myData(token) {
let page = 1;
let response = {ok: true};
while(response.ok) {
response = fetch(
`https://my-data-api.com/?page=${page}`,
{ headers: { authorization: token }}
);
yield response.json(); // Promise<{blogs: any[]}>
page += 1;
}
}
const myDataGenerator = myData(token);
console.log((await myDataGenerator.next()).value); // {"blogs":[....]}
for await (const json of myData(token)) {
console.log(json); // {"blogs":[....]}
}
Take a look at the useful for await...of
loop syntax above, to resolve the Promise returned by async function* myData()
! So much state is being handled internally... It's so clean, so beautiful. How cool is that! π Way.
Conclusion
Common uses for generators include:
pagination/chunking/streaming (eg. an API with
page=1
etc.)looping (like python's
range
)infinite sequences
function throttlers
finite state machines
anything with state you can dream of!
Congrats on making it this far, I thank you! π+π I hope you found this article practical and useful. Now go out there and practice!
You can follow me on Twitter and YouTube. Letβs continue interesting conversations about Sr JS development together!