Advanced Iterator Functionality

You can accomplish a lot with the basic functionality of iterators and the convenience of creating them using generators. However, iterators are much more powerful when used for tasks other than simply iterating over a collection of values. During the development of ECMAScript 6, a lot of unique ideas and patterns emerged that encouraged the creators to add more functionality. Some of those additions are subtle, but when used together, can accomplish some interesting interactions.

Passing Arguments to Iterators

Throughout this chapter, examples have shown iterators passing values out via the next() method or by using yield in a generator. But you can also pass arguments to the iterator through the next() method. When an argument is passed to the next() method, that argument becomes the value of the yield statement inside a generator. This capability is important for more advanced functionality such as asynchronous programming. Here’s a basic example:

  1. function *createIterator() {
  2. let first = yield 1;
  3. let second = yield first + 2; // 4 + 2
  4. yield second + 3; // 5 + 3
  5. }
  6. let iterator = createIterator();
  7. console.log(iterator.next()); // "{ value: 1, done: false }"
  8. console.log(iterator.next(4)); // "{ value: 6, done: false }"
  9. console.log(iterator.next(5)); // "{ value: 8, done: false }"
  10. console.log(iterator.next()); // "{ value: undefined, done: true }"

The first call to next() is a special case where any argument passed to it is lost. Since arguments passed to next() become the values returned by yield, an argument from the first call to next() could only replace the first yield statement in the generator function if it could be accessed before that yield statement. That’s not possible, so there’s no reason to pass an argument the first time next() is called.

On the second call to next(), the value 4 is passed as the argument. The 4 ends up assigned to the variable first inside the generator function. In a yield statement including an assignment, the right side of the expression is evaluated on the first call to next() and the left side is evaluated on the second call to next() before the function continues executing. Since the second call to next() passes in 4, that value is assigned to first and then execution continues.

The second yield uses the result of the first yield and adds two, which means it returns a value of six. When next() is called a third time, the value 5 is passed as an argument. That value is assigned to the variable second and then used in the third yield statement to return 8.

It’s a bit easier to think about what’s happening by considering which code is executing each time execution continues inside the generator function. Figure 8-1 uses colors to show the code being executed before yielding.

Figure 8-1: Code execution inside a generator

The color yellow represents the first call to next() and all the code executed inside of the generator as a result. The color aqua represents the call to next(4) and the code that is executed with that call. The color purple represents the call to next(5) and the code that is executed as a result. The tricky part is how the code on the right side of each expression executes and stops before the left side is executed. This makes debugging complicated generators a bit more involved than debugging regular functions.

So far, you’ve seen that yield can act like return when a value is passed to the next() method. However, that’s not the only execution trick you can do inside a generator. You can also cause iterators throw an error.

Throwing Errors in Iterators

It’s possible to pass not just data into iterators but also error conditions. Iterators can choose to implement a throw() method that instructs the iterator to throw an error when it resumes. This is an important capability for asynchronous programming, but also for flexibility inside generators, where you want to be able to mimic both return values and thrown errors (the two ways of exiting a function). You can pass an error object to throw() that should be thrown when the iterator continues processing. For example:

  1. function *createIterator() {
  2. let first = yield 1;
  3. let second = yield first + 2; // yield 4 + 2, then throw
  4. yield second + 3; // never is executed
  5. }
  6. let iterator = createIterator();
  7. console.log(iterator.next()); // "{ value: 1, done: false }"
  8. console.log(iterator.next(4)); // "{ value: 6, done: false }"
  9. console.log(iterator.throw(new Error("Boom"))); // error thrown from generator

In this example, the first two yield expressions are evaluated as normal, but when throw() is called, an error is thrown before let second is evaluated. This effectively halts code execution similar to directly throwing an error. The only difference is the location in which the error is thrown. Figure 8-2 shows which code is executed at each step.

Figure 8-2: Throwing an error inside a generator

In this figure, the color red represents the code executed when throw() is called, and the red star shows approximately when the error is thrown inside the generator. The first two yield statements are executed, and when throw() is called, an error is thrown before any other code executes.

Knowing this, you can catch such errors inside the generator using a try-catch block:

  1. function *createIterator() {
  2. let first = yield 1;
  3. let second;
  4. try {
  5. second = yield first + 2; // yield 4 + 2, then throw
  6. } catch (ex) {
  7. second = 6; // on error, assign a different value
  8. }
  9. yield second + 3;
  10. }
  11. let iterator = createIterator();
  12. console.log(iterator.next()); // "{ value: 1, done: false }"
  13. console.log(iterator.next(4)); // "{ value: 6, done: false }"
  14. console.log(iterator.throw(new Error("Boom"))); // "{ value: 9, done: false }"
  15. console.log(iterator.next()); // "{ value: undefined, done: true }"

In this example, a try-catch block is wrapped around the second yield statement. While this yield executes without error, the error is thrown before any value can be assigned to second, so the catch block assigns it a value of six. Execution then flows to the next yield and returns nine.

Notice that something interesting happened: the throw() method returned a result object just like the next() method. Because the error was caught inside the generator, code execution continued on to the next yield and returned the next value, 9.

It helps to think of next() and throw() as both being instructions to the iterator. The next() method instructs the iterator to continue executing (possibly with a given value) and throw() instructs the iterator to continue executing by throwing an error. What happens after that point depends on the code inside the generator.

The next() and throw() methods control execution inside an iterator when using yield, but you can also use the return statement. But return works a bit differently than it does in regular functions, as you will see in the next section.

Generator Return Statements

Since generators are functions, you can use the return statement both to exit early and specify a return value for the last call to the next() method. In most examples in this chapter, the last call to next() on an iterator returns undefined, but you can specify an alternate value by using return as you would in any other function. In a generator, return indicates that all processing is done, so the done property is set to true and the value, if provided, becomes the value field. Here’s an example that simply exits early using return:

  1. function *createIterator() {
  2. yield 1;
  3. return;
  4. yield 2;
  5. yield 3;
  6. }
  7. let iterator = createIterator();
  8. console.log(iterator.next()); // "{ value: 1, done: false }"
  9. console.log(iterator.next()); // "{ value: undefined, done: true }"

In this code, the generator has a yield statement followed by a return statement. The return indicates that there are no more values to come, and so the rest of the yield statements will not execute (they are unreachable).

You can also specify a return value that will end up in the value field of the returned object. For example:

  1. function *createIterator() {
  2. yield 1;
  3. return 42;
  4. }
  5. let iterator = createIterator();
  6. console.log(iterator.next()); // "{ value: 1, done: false }"
  7. console.log(iterator.next()); // "{ value: 42, done: true }"
  8. console.log(iterator.next()); // "{ value: undefined, done: true }"

Here, the value 42 is returned in the value field on the second call to the next() method (which is the first time that done is true). The third call to next() returns an object whose value property is once again undefined. Any value you specify with return is only available on the returned object one time before the value field is reset to undefined.

I> The spread operator and for-of ignore any value specified by a return statement. As soon as they see done is true, they stop without reading the value. Iterator return values are helpful, however, when delegating generators.

Delegating Generators

In some cases, combining the values from two iterators into one is useful. Generators can delegate to other iterators using a special form of yield with a star (*) character. As with generator definitions, where the star appears doesn’t matter, as long as the star falls between the yield keyword and the generator function name. Here’s an example:

  1. function *createNumberIterator() {
  2. yield 1;
  3. yield 2;
  4. }
  5. function *createColorIterator() {
  6. yield "red";
  7. yield "green";
  8. }
  9. function *createCombinedIterator() {
  10. yield *createNumberIterator();
  11. yield *createColorIterator();
  12. yield true;
  13. }
  14. var iterator = createCombinedIterator();
  15. console.log(iterator.next()); // "{ value: 1, done: false }"
  16. console.log(iterator.next()); // "{ value: 2, done: false }"
  17. console.log(iterator.next()); // "{ value: "red", done: false }"
  18. console.log(iterator.next()); // "{ value: "green", done: false }"
  19. console.log(iterator.next()); // "{ value: true, done: false }"
  20. console.log(iterator.next()); // "{ value: undefined, done: true }"

In this example, the createCombinedIterator() generator delegates first to the iterator returned from createNumberIterator() and then to the iterator returned from createColorIterator(). The iterator returned from createCombinedIterator() appears, from the outside, to be one consistent iterator that has produced all of the values. Each call to next() is delegated to the appropriate iterator until the iterators created by createNumberIterator() and createColorIterator() are empty. Then the final yield is executed to return true.

Generator delegation also lets you make further use of generator return values. This is the easiest way to access such returned values and can be quite useful in performing complex tasks. For example:

  1. function *createNumberIterator() {
  2. yield 1;
  3. yield 2;
  4. return 3;
  5. }
  6. function *createRepeatingIterator(count) {
  7. for (let i=0; i < count; i++) {
  8. yield "repeat";
  9. }
  10. }
  11. function *createCombinedIterator() {
  12. let result = yield *createNumberIterator();
  13. yield *createRepeatingIterator(result);
  14. }
  15. var iterator = createCombinedIterator();
  16. console.log(iterator.next()); // "{ value: 1, done: false }"
  17. console.log(iterator.next()); // "{ value: 2, done: false }"
  18. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  19. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  20. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  21. console.log(iterator.next()); // "{ value: undefined, done: true }"

Here, the createCombinedIterator() generator delegates to createNumberIterator() and assigns the return value to result. Since createNumberIterator() contains return 3, the returned value is 3. The result variable is then passed to createRepeatingIterator() as an argument indicating how many times to yield the same string (in this case, three times).

Notice that the value 3 was never output from any call to the next() method. Right now, it exists solely inside the createCombinedIterator() generator. But you can output that value as well by adding another yield statement, such as:

  1. function *createNumberIterator() {
  2. yield 1;
  3. yield 2;
  4. return 3;
  5. }
  6. function *createRepeatingIterator(count) {
  7. for (let i=0; i < count; i++) {
  8. yield "repeat";
  9. }
  10. }
  11. function *createCombinedIterator() {
  12. let result = yield *createNumberIterator();
  13. yield result;
  14. yield *createRepeatingIterator(result);
  15. }
  16. var iterator = createCombinedIterator();
  17. console.log(iterator.next()); // "{ value: 1, done: false }"
  18. console.log(iterator.next()); // "{ value: 2, done: false }"
  19. console.log(iterator.next()); // "{ value: 3, done: false }"
  20. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  21. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  22. console.log(iterator.next()); // "{ value: "repeat", done: false }"
  23. console.log(iterator.next()); // "{ value: undefined, done: true }"

In this code, the extra yield statement explicitly outputs the returned value from the createNumberIterator() generator.

Generator delegation using the return value is a very powerful paradigm that allows for some very interesting possibilities, especially when used in conjunction with asynchronous operations.

I> You can use yield * directly on strings (such as yield * "hello") and the string’s default iterator will be used.