Modern JavaScript: Defining Iterators
Welcome to the Defining Iterators lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
We've seen several ways to iterate over data structures in JavaScript. For example, we've used for-of loops to iterate over arrays:
>
const numbers = [1, 2, 3];const times2 = [];for (const n of numbers) {times2.push(n * 2);}times2;Result:
[2, 4, 6]
We've also accessed array elements by destructuring them.
>
const numbers = [1, 2, 3];const [firstNumber, ...rest] = numbers;rest;Result:
[2, 3]
For-of loops and destructuring also work on strings, where they iterate over each character independently.
>
const aString = 'abc';const uppercaseLetters = [];for (const letter of aString) {const upper = letter.toUpperCase();uppercaseLetters.push(upper);}uppercaseLetters;Result:
['A', 'B', 'C']
>
const aString = 'abc';const [firstLetter] = aString;firstLetter;Result:
'a'
Loops and destructuring work on sets and maps as well. For example, we can loop over a set.
>
const names = new Set(['amir', 'betty']);const uppercaseNames = [];for (const name of names) {uppercaseNames.push(name.toUpperCase());}uppercaseNames;Result:
['AMIR', 'BETTY']
JavaScript only comes with a relatively small number of data types built-in. We might use third-party libraries to get a tree or graph data type, or some other more exotic data type. Sometimes we want loops and destructuring to work with these data types as well, even though they're not built into the language.
Fortunately, that's possible using JavaScript's iteration protocols. The iteration protocols are a general-purpose way to access the elements of any data type. Loops and destructuring automatically use these protocols to access elements. That's how they're able to iterate over arrays, strings, and sets in the examples above.
Arrays, strings, sets, and maps are all "iterables", meaning that we can iterate over them. ("Iterate" means "perform repeatedly".) For example, when we use a loop to iterate over an array, the loop's body is performed repeatedly, once for each array element.
There are two sides to every iteration. Something does the iterating (like a loop or the destructuring syntax), and something is iterated over (like an array, string, set, or other iterable). For the iteration to work, both sides must follow the iteration protocols.
This lesson focuses on two things: first, the iteration protocols themselves; and second, how to turn our own custom data types into iterables. In real-world systems, we rarely implement the protocols for a new data type, but we implicitly use the iteration protocols every day when writing loops. It's good to understand this topic even if you only use it directly once per year.
To iterate over a JavaScript value, we first ask it for an iterator. Iterators are objects that remember our iteration progress. You can think of them like the cursor in a text editor. The cursor points to one character at a time, and we can move it from one character to another. Likewise, iterators give us one element from an array (or string, or set, or other data type) at a time, and we can move the iterator forward to get the next element.
For example, imagine that we're looping over an array. The iterator gives us the value at index 0, then the value at index 1, etc. It remembers "we've looked at indexes 0 and 1, so the next index to loop over is 2." At each point, the iterator tracks our progress and remembers our place in the array.
We get an iterator by calling the
Symbol.iteratormethod. This symbol is defined by JavaScript for exactly this purpose. Arrays are iterable by default, so they already have aSymbol.iteratormethod.>
const letters = ['a', 'b', 'c'];const iterator = letters[Symbol.iterator]();Our new iterator object is ready to give us the array's first element. We get the element by calling the iterator's
.next()method.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
iterator.next();Result:
The
.next()method always returns an object like the one above, withvalueanddoneproperties.The first call returned the first element in the array,
'a'. The second call returns the next element,'b', and so on.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
iterator.next();Result:
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
iterator.next();Result:
{value: 'c', done: false} Now we've iterated over all three of the
letters. When we call.next()again, it tells us that there are no more elements to iterate over. This time,doneistrue, andvalueisundefined.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
iterator.next();Result:
{value: undefined, done: true} If we continue to call
iterator.next(), it will return the same object as above. The iterator is now "exhausted": there's nothing left to iterate over. If we want to iterate over the array again, we'll need to get a new iterator by callingletters[Symbol.iterator]()and starting over with that new iterator.To summarize the iteration protocols:
- We get an iterator by calling
someIterableObject[Symbol.iterator](). - We call the iterator's
.next()method repeatedly, looking at.valueeach time. - When
iterator.next().doneistrue, we stop. The iterator is now exhausted. There's nothing more that we can do with it.
- We get an iterator by calling
Arrays are a built-in iterable because they have a
Symbol.iteratormethod that returns an iterator. But the most interesting thing about iterators is that we can define our own iterable objects.We'll create a custom iterable object, then loop over it with a for-of loop. Our custom iterable,
NumbersBelowThree, will return numbers 0, 1, and 2.(We could use a tree, graph, or other complex data structure for our examples. But that would involve a lot more code, which would overshadow the iterator protocol code itself. Fortunately, the iterator protocols are the same regardless of what data structure we implement.)
To start, we define a custom iterator object with a
.next()method. Calling.next()returns an object with the keysvalueanddone.>
class NumberIterator {constructor() {this.value = 0;}next() {if (this.value < 3) {const value = this.value;this.value += 1;return {value, done: false};} else {return {value: undefined, done: true};}}}Result:
Our iterator starts with a
valueof 0. The first three iterations returnvalues 0, 1, and 2, each withdone: false. On the fourth iteration, we hit theelsebranch of our conditional, and return{value: undefined, done: true}.Now that we have a custom iterator, we can create an iterable object called
NumbersBelowThree. ItsSymbol.iteratormethod returns an instance ofNumberIterator.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
class NumbersBelowThree {[Symbol.iterator]() {return new NumberIterator();}}Result:
NumbersBelowThreeis now an iterable. That means operators that work on iterables will work on it as well. For example, we can loop over it with a for-of loop!- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const numbers = [];for (const n of new NumbersBelowThree()) {numbers.push(n);}numbers;Result:
[0, 1, 2]
Array destructuring also works.
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const [firstNumber, secondNumber] = new NumbersBelowThree();secondNumber;Result:
1
We can rewrite the examples above without using classes if we prefer that style. This time, we'll define the iterable as a literal object with a
Symbol.iteratorshorthand method. Instead of aNumberIteratorclass, we'll define amakeIteratorfunction that returns a new iterator object. The example below is equivalent to the class examples above.>
const numbersBelowThree = {[Symbol.iterator]: () => makeIterator()};function makeIterator() {let currentValue = 0;return {next() {const value = currentValue;currentValue += 1;if (value < 3) {return {value, done: false};} else {return {value: undefined, done: true};}}};}const numbers = [];for (const n of numbersBelowThree) {numbers.push(n);}numbers;Result:
[0, 1, 2]
The class approach and the object approach are both fine. In some situations, one will be a better match than the other. But in many cases, it's a matter of choosing the approach you prefer or are more familiar with. When modifying existing code, it's usually best to continue using whichever style is already in place.
The examples above may seem like a lot of code for what they do. It's true! Implementing iterators manually always feels a bit verbose. But in return, we get the convenience of using loops and destructuring on custom data types.
A different lesson in this course covers generators, which let us replace the wordy examples above with a three-line function.