One of the more nuanced features introduced in ES6 is that of Generator functions
. Generators offer a powerful, yet often misunderstood mechanism for controlling the flow of operations, allowing developers to implement solutions with improved readability and efficiency. This article briefly delves into a few of the benefits that JavaScript Generators have to offer, elucidating on their purpose, functionality, and specific scenarios which can benefit from their usage.
Understanding Generators
A Generator function is a special type of function that can pause execution and subsequently resume at a later time, making it quite valuable for handling asynchronous operations as well as many other use cases. Unlike regular functions which run to completion upon invocation, Generator functions return an Iterator through which their execution can be controlled. It is important to note that while generators facilitate asynchronous operations, they do so by yielding Promises and require external mechanisms, such as async/await
or libraries, to handle the asynchronous resolution.
Generator Syntax and Operation
Generators
are defined with the function
keyword followed by an asterisk (*
); i.e. (function*)
, and are instantiated when called, but not executed immediately. Rather, they wait for the caller to request the next result. This is achieved using the Iterator.next() method, which resumes execution until the next yield statement is encountered, or the generator function returns.
1 2 3 4 5 6 7 8 9 10 11 | function* generatorExample() { yield 'First'; yield 'Second'; }; // create the generator, then get the values ... const generator = generatorExample(); console.log(generator.next().value); // "First" console.log(generator.next().value); // "Second" console.log(generator.next().done); // true |
Generator Iterators
As mentioned, Generator
functions return an Iterator, therefore, all functionality of Iterables are available to them, such as for...of
loops, destructuring
, ...rest parameters
, etc.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | function* generatorExample() { yield 'First'; yield 'Second'; }; // for...of loops for (const value of generatorExample()) { console.log(value); // "First", "Second" } // array destructuring ... const items = [...generatorExample(), 'Third']; console.log(items); // ["First", "Second", "Third"] // ...rest parameters const updateItems = (...items) => { console.log(items); }; updateItems(...generatorExample()); // "First", "Second" |
Custom Iterations
Generators allow for the creation of custom iteration logic, such as generating sequences without the need to pre-calculate the entire set. For example, one can generate a Fibonacci sequence using generators as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 | function* fibonacci(limit) { let [previous, current] = [0, 1]; while (limit-- > 0) { [previous, current] = [current, previous + current]; yield current; } }; // each iteration yields the next number in the sequence ... for (const num of fibonacci(6)) { console.log(num); // 1, 2, 3, 5, 8, 13 }; |
Stateful Iterations
Generators
have the ability to maintain state between yields
, thus they are quite useful for managing stateful iterations. This feature can be leveraged in scenarios such as those which require pause and resume logic based on runtime conditions. For instance:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | const gameState = function*() { let gameOver = false; let score = 0; while (!gameOver) { // receive, increment, and yield the updated score // here, the "score" that is yielded is a reference // to the value passed to .next gameOver = (score += yield score) >= 10; } return 'Game Over'; // return 'Game Over' when the loop ends }; // create the game generator ... const game = gameState(); // starting the game, the first call initializes the game // and returns the initial score ... console.log(game.next().value); // 0 // simulate score increments ... console.log(game.next(1).value); // 1 console.log(game.next(2).value); // 3 console.log(game.next(3).value); // 6 console.log(game.next(4).value); // "Game Over" // subsequent calls will continue to return undefined as // the game has completed ... console.log(game.next(10).value); // undefined console.log(game.next().done); // true |
It may initially seem confusing as to how the value passed to game.next(value)
is referenced within the Generator function. However, it is important to understand how this mechanism works as it is a core feature of generators, allowing them to interact dynamically with external input. Below is a breakdown outlining this behavior in the context of the above example:
- Starting the Generator: When
game.next()
is first called, thegameState
generator function begins execution until it reaches the firstyield
statement. This initial call starts the generator but does not yet pass any value into it, as the generator is not yet paused at ayield
that could receive a value. - Pausing Execution: The
yield
statement pauses the generator’s execution and waits for the next input to be provided. This pausing mechanism is what differentiates generators from regular functions, allowing for a two-way exchange of values. - Resuming with a Value: After the generator is initiated and paused at a
yield
, callinggame.next(value)
resumes execution, passing thevalue
into the generator. This passed value is received by theyield
expression where the generator was paused. - Processing and Pausing Again: Once the generator function receives the value and resumes execution, it processes operations following the
yield
until it either encounters the nextyield
(and pauses again, awaiting further input), reaches areturn
statement (effectively ending the generator’s execution), or completes its execution block.
This interactive capability of generators to receive external inputs and potentially alter their internal state or control flow based on those inputs is what makes them particularly powerful for tasks requiring stateful iterations or complex control flows.
Generator Returns
In addition to yielding values with yield
, generators have a distinct behavior when it comes to the return
statement. A return
statement inside a generator function does not merely exit the function, but instead, it provides a value that can be retrieved by the iterator. This behavior allows generators to signal a final value before ceasing their execution.
When a generator encounters a return
statement, it returns an object with two properties: value
, which is the value specified by the return
statement, and done
, which is set to true
to indicate that the generator has completed its execution. This is different from the yield
statement, which also returns an object but with done
set to false
until the generator function has fully completed.
1 2 3 4 5 6 7 8 9 10 | function* returnExampleGenerator() { yield 'First'; return 'Finished'; } const generator = returnExampleGenerator(); console.log(generator.next().value); // "First" console.log(generator.next()); // {value: "Finished", done: true} |
This example illustrates that after the return
statement is executed, the generator indicates it is done, and no further values can be yielded. However, the final value returned by the generator can be used to convey meaningful information or a result to the iterator, effectively providing a clean way to end the generator’s execution while also returning a value.
Generators also provide a return() method that can be used to terminate the generator’s execution prematurely. When return()
is called on a generator object, the generator is immediately terminated and returns an object with a value
property set to the argument provided to return()
, and a done
property set to true
. This method is especially useful for allowing clients to cleanly exit generator functions, such as for ensuring resources are released appropriately, etc..
1 2 3 4 5 6 7 8 9 10 11 12 | function* generatorExample() { yield 'First'; yield 'Second'; } const generator = generatorExample(); console.log(generator.next().value); // Logs "First" // terminate the generator early ... console.log(generator.return('Last').value); // Logs "Last" console.log(generator.next()); // {value: undefined, done: true} |
In this example, after the first yield is consumed, return()
is invoked on the generator. This action terminates the generator, returns the provided value, and sets the done
property of the generator to true
, indicating that the generator has completed and will no longer yield
values.
This capability of generators to be terminated early and cleanly, returning a specified value, provides developers fine-grained control over generator execution.
Error Handling in Generators
Generators provide a robust mechanism for error handling, allowing errors to be thrown back into the generator’s execution context. This is accomplished using the generator.throw() method. When an error is thrown within a generator, the current yield
expression is replaced by a throw statement, causing the generator to resume execution. If the thrown error is not caught within the generator, it propagates back to the caller.
This feature is particularly useful for managing errors in asynchronous operations, enabling developers to handle errors in a synchronous-like manner within the asynchronous control flow of a generator.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function* errorHandlingGenerator() { try { yield 'First'; // error thrown here will be caught by the catch block below yield 'Second'; } catch (error) { console.log('Caught error:', error.message); } } const generator = errorHandlingGenerator(); console.log(generator.next().value); // "First" // Simulate an error generator.throw(new Error('Something went wrong')); |
This example illustrates how generator.throw()
can be used to simulate error conditions and test error handling logic within generators. It also shows how generators maintain their state and control flow, even in the presence of errors, providing a powerful tool for asynchronous error management.
Generator Composition
One particularly interesting feature of Generators
is that they can be composed of other generators via the yield* operator.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | const generator1 = function*() { yield 1; yield 2; yield 3; }; const generator2 = function*() { yield 'One'; yield 'Two'; yield 'Three'; }; const generator = function*() { yield* generator1(); yield* generator2(); } // for...of loops for (let value of generator()) { console.log(value); // 1, 2, 3, "One", "Two", "Three" } |
The ability to compose Generators
allows for implementing various levels of abstraction and reuse, making their usage much more flexible.
Concluding Thoughts
Generators
can be used for many purposes, ranging from basic use-cases such as generating a sequence of numbers, to more complex scenarios such as handling streams of data so as to allow for processing input as it arrives. Through the brief examples above, we’ve seen how Generators can improve the way we, as developers, approach implementing solutions for asynchronous programming, iteration, and state management.