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:
function *createIterator() {
let first = yield 1;
let second = yield first + 2; // 4 + 2
yield second + 3; // 5 + 3
}
let iterator = createIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next(4)); // "{ value: 6, done: false }"
console.log(iterator.next(5)); // "{ value: 8, done: false }"
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.
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:
function *createIterator() {
let first = yield 1;
let second = yield first + 2; // yield 4 + 2, then throw
yield second + 3; // never is executed
}
let iterator = createIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next(4)); // "{ value: 6, done: false }"
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.
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:
function *createIterator() {
let first = yield 1;
let second;
try {
second = yield first + 2; // yield 4 + 2, then throw
} catch (ex) {
second = 6; // on error, assign a different value
}
yield second + 3;
}
let iterator = createIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next(4)); // "{ value: 6, done: false }"
console.log(iterator.throw(new Error("Boom"))); // "{ value: 9, done: false }"
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
:
function *createIterator() {
yield 1;
return;
yield 2;
yield 3;
}
let iterator = createIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
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:
function *createIterator() {
yield 1;
return 42;
}
let iterator = createIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next()); // "{ value: 42, done: true }"
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:
function *createNumberIterator() {
yield 1;
yield 2;
}
function *createColorIterator() {
yield "red";
yield "green";
}
function *createCombinedIterator() {
yield *createNumberIterator();
yield *createColorIterator();
yield true;
}
var iterator = createCombinedIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next()); // "{ value: 2, done: false }"
console.log(iterator.next()); // "{ value: "red", done: false }"
console.log(iterator.next()); // "{ value: "green", done: false }"
console.log(iterator.next()); // "{ value: true, done: false }"
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:
function *createNumberIterator() {
yield 1;
yield 2;
return 3;
}
function *createRepeatingIterator(count) {
for (let i=0; i < count; i++) {
yield "repeat";
}
}
function *createCombinedIterator() {
let result = yield *createNumberIterator();
yield *createRepeatingIterator(result);
}
var iterator = createCombinedIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next()); // "{ value: 2, done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
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:
function *createNumberIterator() {
yield 1;
yield 2;
return 3;
}
function *createRepeatingIterator(count) {
for (let i=0; i < count; i++) {
yield "repeat";
}
}
function *createCombinedIterator() {
let result = yield *createNumberIterator();
yield result;
yield *createRepeatingIterator(result);
}
var iterator = createCombinedIterator();
console.log(iterator.next()); // "{ value: 1, done: false }"
console.log(iterator.next()); // "{ value: 2, done: false }"
console.log(iterator.next()); // "{ value: 3, done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
console.log(iterator.next()); // "{ value: "repeat", done: false }"
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.