Deep dive into JavaScript Generator Functions & Generators

Deep dive into JavaScript Generator Functions & Generators

Learn what generators are and understand how do they work and how to use them.

·

7 min read

  • In JavaScript, when a function is called, it executes the code within its body until it reaches a return statement (if a function doesn't have a return statement, it returns undefined). We can say that we have no control over the flow of the functions. which means, we cannot make a function make multiple return at different stepped calls, or make the function's code stop after a certain block of code...

  • Contrary to the function's flow of control. There's something called generators in JavaScript that can yield (return) multiple times, one after another.

  • To create a generator, we need a generator function which is defined with the function* syntax, as follow:

// Defining our function generator
function* fnGenerator() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

// Create a generator instance from the generator function
const generator = fnGenerator();

console.log(generator); // Iterator [Generator] {}

The generator instance has 3 methods:

  • next()
  • return()
  • throw()

The Next() method

  • It returns an object that represents the state of our generator.
  • The returned object has 2 properties: done and value.
  • The value property contains the latest yield value, when the next() method is called. if there's no value in a yield statement, then it yields undefined by default.
  • The done property which is a boolean, refers to whether we've iterated through all the sequences of our iterable. When the next() methods returns the value of the return statement in our Function Generator, by default the generator instance's done because true and we can no longer iterate through it, unless we initialize a new generator instance or reset it (we'll see how to do that later).
  • It runs the code of the Function Generator until it reaches the nearest yield statement yield value_to_yield.
  • After reaching the yield the code execution pauses there until the next call for next of return.

  • Example

// Defining our function generator
function* fnGenerator() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

// Create a generator instance from the generator function
const generator = fnGenerator();

console.log(generator.next()) // { value: 1, done: false }
console.log(generator.next()) // { value: 2, done: false }
console.log(generator.next()) // { value: 3, done: false }
console.log(generator.next()) // { value: 4, done: true }

// Now that our generator is done, what if we call next again?
console.log(generator.next()) // { value: undefined, done: true }
console.log(generator.next()) // { value: undefined, done: true }
  • After our generator's done is true, we say that it's complete and we cannot reuse it or reset. If we'd still need one, we have to initialize a new instance.

The Return() method

  • It returns the given value to the generator and finishes the generator (sets its done property to true).
  • It takes an optional argument. It updates the the value property of the generator's returned / yielded value (Example II). If no parameter is passed, then the value becomes undefined (Example I).

  • Example I

function* gen() {
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

const g = gen();
g.next() // { value: 1, done: false }
g.return() // { value: undefined, done: true }
  • Example II
function* gen() {
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

const g = gen();
g.next() // { value: 1, done: false }
g.return(9999) // { value: 9999, done: true }

The throw() method

  • It allows us to throw an error into a generator.
  • It returns the same object as the next and the return methods.
  • You pass to it the exception to throw throw(exception).
  • You use this method if you're handling errors in your Function Generator.
  • If you're not familiar with error handling in JavaScript, check this documentation.
  • This method isn't used as much as the next method. Check this example

Iterating through a generator

  • You can go through a generator's yielded values by using the next() methods as many times as possible until it's done and you can also loop through them (the value property and not the entire object that represents the state of the generator) with the for ... of as follow:
function* fnGenerator() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

let iterator = fnGenerator();

for (let v of iterator) {
  console.log(v);
}
/*
output will be:
1
2
3
*/
  • If you want to loop through it and get the whole state (returned object that represents the yielded value), you can do according to the done value (as long as done isn't true a.k.a as long as it's not completed yet), as follow:
function* fnGenerator() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

let iterator = fnGenerator();

let next;

while (!(next = iterator.next()).done) {
   console.log("Current state = ", next)
}

/*
output will be:
Current state =  {value: 1, done: false}
Current state =  {value: 2, done: false}
Current state =  {value: 3, done: false}
*/

Reset generator's state

  • You can reset the generator's yielded value to its initial value or update by passing an argument to the next method.
  • The next method takes an optional parameter. The passed value to the next method will be assigned as a result of a yield statement.

  • Example

  • Our function generator

function* dumpGen() {
  let count = 0;
  while (count < 3) {
    let reset = yield count += 1;
    if (reset === 0) {
      count = 0;
    }
  }
}
  • Our first iterator without state reset
let iterator1 = dumpGen();

iterator1.next();
iterator1.next();
iterator1.next();
iterator1.next();

/*
output will be:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
*/
  • As you can see in the example with the iterator1 above, we haven't passed anything to the next method to update or reset the generator's state. Now let's see what's going to happen when we pass 0.
let iterator2 = dumpGen();

iterator2.next();
iterator2.next();
iterator2.next();
iterator2.next(0);
iterator2.next();

*/
output will be:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: 1, done: false }
{ value: 2, done: false }
  • In the 2nd example with iterator2, when we passed the 0 argument to the next, the generator got back to its initial state. What happened is that when we passed 0 to the next method, we assigned it to the reset variable. But we did that before the generator is complete (done === true).

  • If we try to reset the state after done has become true, nothing will change, because once a generator is complete, you cannot reset it:

let iterator2 = dumpGen();

iterator2.next();
iterator2.next();
iterator2.next();
iterator2.next();
iterator2.next(0);
iterator2.next();

/*
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
{ value: undefined, done: true }
{ value: undefined, done: true }
*/

Iterate through a generator's arguments

  • You can pass to the generator as many arguments as you wish.
  • You can loop through those arguments in 3 different ways

1st way

function *dumpGen() {
  for (let arg of arguments) {
    yield arg;
  }
}

let iterator = dumpGen(1,2,3,4,5);

iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();

/*
output will be:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: 4, done: false }
{ value: 5, done: false }
{ value: undefined, done: true }
*/

2nd way

function *dumpGen() {
  for (let i = 0; i < arguments.length; i++) {
    yield arguments[i];
  }
}

let iterator = dumpGen(1,2,3,4,5);

iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();

/*
output will be:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: 4, done: false }
{ value: 5, done: false }
{ value: undefined, done: true }

3rd way

function *dumpGen() {
  yield* arguments
}

let iterator = dumpGen(1,2,3,4,5);

iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();
iterator.next();

/*
output will be:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: 4, done: false }
{ value: 5, done: false }
{ value: undefined, done: true }
*/
  • In the 3rd way, the yield* mimics the for loop to go through the arguments.

Deconstruction

  • You can deconstruct and get a generator's yielded values at once, as follow:
function *dumpGen() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
}

let arr = [...dumpGen()];

console.log(arr); // [1, 2, 3, 4]

Conclusion

  • Function generators return a Generator object that behaves as iterator.
  • We use yield to pause a function's flow to produce an undone state with a certain value.
  • An iterator is mostly used with the next() method to get the most recent yielded state.
  • if you want to read more about Generators, check the following resources:
  • MDN
  • javascript.info