An iterator provides a way to iterate over a collection of values. One of the most common uses for an iterator is in for...of loops. A for...of loop will loop over the values given to it, but how does it know what order to use?
For example, why does this for...of loop start at index 0? Why not start at the end and go backwards?
const arrayOfFruits = ['apple', 'orange', 'banana', 'pear'];
for (const val of arrayOfFruits) {
console.log(val);
}
// Expected output:
// apple
// orange
// banana
// pear
The answer is that Array
has a built-in iterator and it's implemented to start from index 0 and move forward through the array. While that's a useful way to iterate over the elements of an array, it isn't the only way to iterate over them. We could implement our own iterator that chooses a different order.
To implement a custom iterator there are some rules you must follow. All iterators must implement an interface like:
interface Iterator {
next: () => {
done: boolean;
value: any;
};
}
Here is an iterator that iterates through an array backwards:
class BackwardsIterator {
values: any[];
index: number;
constructor(values: any[]) {
this.values = values;
this.index = values.length;
}
next() {
if (this.index - 1 < 0) {
return { done: true };
}
this.index--;
return {
done: false,
value: this.values[this.index],
};
}
}
You can see it in action by repeatedly calling its next()
method:
const arrayOfFruits = ['apple', 'orange', 'banana', 'pear'];
const backwardsIter = new BackwardsIterator(arrayOfFruits);
let result = backwardsIter.next();
while (!result.done) {
console.log(result.value);
result = backwardsIter.next();
}
// Expected output:
// pear
// banana
// orange
// apple
Now that we have a custom iterator, let's see how we can get it to work with for...of loops. When we pass a collection or object to a for...of loop it will look for a property that returns an iterator. Specifically it will look for the special property [Symbol.iterator]
that when executed should return an iterator. Objects with this property are called iterable. Iterables must implement an interface like:
interface Iterable {
[Symbol.iterator]: () => Iterator;
}
Let's create our own iterable class that holds a list of words and uses BackwardsIterator
:
class WordList {
words: string[] = [];
add(word: string) {
this.words.push(word);
}
[Symbol.iterator]() {
return new BackwardsIterator(this.words);
}
}
By giving WordList
the special method [Symbol.iterator]
we've made it iterable and you can use it directly in for...of loops. Another important detail is that we return a new iterator each time [Symbol.iterator]
is called. This allows you to iterate as many times as you want without those iterators interfering with each other.
const places = new WordList();
places.add('New York');
places.add('Paris');
places.add('Mexico City');
for (const p of places) {
console.log(p);
}
// Expected output:
// Mexico City
// Paris
// New York
Implementing iterators and iterables can be verbose sometimes, but luckily the language provides an alternative syntax that makes it easier to express the behavior we want. Instead of writing iterables that provide iterators (like above) we can create something called a generator which is both an iterable and an iterator. Generators also give us some special syntax for returning values.
The following generator function does the same thing as our BackwardsIterator
and WordList
classes:
function* backwardsGeneratorFunction(values: any[]) {
let index = values.length;
while (index - 1 >= 0) {
index--;
yield values[index];
}
}
Notice the *
in function*
and the use of the yield
keyword. Generator functions are declared with function*
, which means you can't make an arrow function a generator function. The yield
keyword is how a generator "returns" values to the caller and can only be used inside a generator function (just like await
can only be used inside an async
function). When you execute a generator function it returns a generator.
const powersOfTwo = [2, 4, 8, 16, 32];
const backwardsGenerator = backwardsGeneratorFunction(powersOfTwo);
// backwardsGenerator is an iterator
console.log(typeof backwardsGenerator.next);
// Expected output:
// function
// backwardsGenerator is also iterable
console.log(typeof backwardsGenerator[Symbol.iterator]);
// Expected output:
// function
console.log(backwardsGenerator.next());
console.log(backwardsGenerator.next());
// Expected output:
// {value: 32, done: false}
// {value: 16, done: false}
Generators are special in that they pause execution when they get to a yield
statement. We called next()
twice and each time the generator ran until it hit yield
, returned the yielded value, and then paused.
Since a generator is both an iterable and an iterator we can use it directly in for...of loops:
const powersOfTwo = [2, 4, 8, 16, 32];
const backwardsGenerator = backwardsGeneratorFunction(powersOfTwo);
for (const val of backwardsGenerator) {
console.log(val);
}
// Expected output:
// 32
// 16
// 8
// 4
// 2