JS Tip of the Day: Interacting With Generators

Interacting With Generators
Version: ES2015
Level: Advanced

The iterator protocol provides methods for interacting with iterators. These include next() and optionally return() and throw(). Each of these are used both for pulling data from iterators (iterator result objects) as well as sending data to them. When working with generators, these methods relate directly to operations in the execution of the generator function.

Normal usage of the next() method will run the generator function of the generator object up until it reaches the next yield in its execution. The value yielded is set as the value property of the iterator result object returned back to the next() call.

function * counter () {
    yield 1;
    yield 2;
    yield 3;
}

let count = counter();

// run counter() up to yield 1
console.log(count.next()); // {value: 1, done: false}

// continue running counter() up to yield 2
console.log(count.next()); // {value: 2, done: false}

The next() function can also be passed a value. This value is becomes the what the previous yield expression in the generator resolves to.

function * counter () {
    let fromNext = yield 1; // returns what's passed to next()
    console.log('from next:', fromNext);
    yield 2;
    yield 3;
}

let count = counter();
count.next(); // (starts function)
count.next('there'); // from next: there

Since the value passed to next() is tied to a yield in the generator, there’s currently no way to get the value in the first next() call that starts the generator function. However there is a proposal (stage 2) for doing this using function.sent.

function * counter () {
    console.log('first next:', function.sent); // proposed
    let fromNext = yield 1;
    console.log('from next:', fromNext);
    yield 2;
    yield 3;
}

let count = counter();
count.next('hello'); // first next: hello
count.next('there'); // from next: there

Because you can pass values into next(), what it sends to the generator can be used to alter how the generator produces additional values.

function * counter () {
    let stopCounting = yield 1;
    if (stopCounting) {
        return;
    }
    yield 2;
    yield 3;
}

let count = counter();
console.log(count.next()); // {value: 1, done: false}
console.log(count.next(true)); // {value: undefined, done: true}

Here, passing true to the generator through next() will cause it to return, exiting early. However, for this specific case, a condition like this isn’t actually needed because this behavior is already built-in using the return() method.

When using return() the generator will return immediately from its current point of execution in the function. Anything you pass into the return() method will be returned from the generator function and ultimately given back to the iterator result object.

function * counter () {
    yield 1;
    yield 2;
    yield 3;
}

let count = counter();
console.log(count.next()); // {value: 1, done: false}
console.log(count.return('end')); // {value: 'end', done: true}

The result is the same as though a return statement immediately followed the respective yield.

function * counter () {
    yield 1;
    return 'end';
    yield 2;
    yield 3;
}

let count = counter();
console.log(count.next()); // {value: 1, done: false}
console.log(count.next()); // {value: 'end', done: true}

throw() works much the same way except it will inject a throw statement into the execution of the generator, throwing whatever argument it was given when called.

function * counter () {
    try {
        yield 1;
        yield 2;
        yield 3;
    } catch (error) {
        console.log('Error:', error);
    }
}

let count = counter();
console.log(count.next()); // {value: 1, done: false}
console.log(count.throw('wanted letters')); // Error: wanted letters
// { value: undefined, done: true }

This being the same as…

function * counter () {
    try {
        yield 1;
        throw 'wanted letters';
        yield 2;
        yield 3;
    } catch (error) {
        console.log('Error:', error);
    }
}

let count = counter();
console.log(count.next()); // {value: 1, done: false}
console.log(count.next()); // Error: wanted letters
// { value: undefined, done: true }

Bear in mind that both return() and throw() suffer similar problems for the first invocation of a generator as does next(). If the initial call to a generator object was a throw(), it’d have no way to handle it because it would be thrown before the generator would have time to even reach a try...catch statement.

function * counter () {
    try {
        yield 1;
        yield 2;
        yield 3;
    } catch (error) {
        console.log('Error:', error);
    }
}

let count = counter();
console.log(count.throw('wanted letters')); // Uncaught wanted letters

Also, while all generators inherently implement return() and throw(), they are not required methods for all iterators. If you are working with some arbitrary iterator which may not have originated from a generator, you may want to see if these methods exist before attempting to call them.

More info: