Symbols Are Your Friend Series
So far, our first 2 looks at Symbol have been straightforward. We already know Symbol
is a constructor that returns a symbol object which we already looked at, however this function also has a number of static properties including Symbol.iterator
which is a method that returns the default iterator for an object. This one property of Symbol
is so important and involved that it needs it's own focus...
When you first research what an iterator is, you'll often come across vague definitions like this:
Iterator: Let's you iterate over a collection.
But what exactly does this mean? To start, let's define what an iteration is: it's simply a repetition of a process. In JavaScript, a loop is an instruction to repeat until a certain condition is reached. Each repetition in a loop is called an iteration.
Next, let's define what iterable means. To say that an object is iterable means that it has the capability to have its values looped over with certain statements and expressions like for...of
or yield*
:
const lotteryNumbers = [16, 32, 7];
for (const num of lotteryNumbers) {
console.log(num); // Logs num on each iteration
}
These types of loops are different than your standard for
or forEach()
loops. We'll explain that more soon...
Iterable objects are those whose prototype includes the Symbol.iterator
key. Since arrays are iterable, you can see this when you inspect its prototype:
Other iterable objects include String
, Map
, Set
. Note that Object
is NOT iterable by default.
Now for the hardest definition of iterator. An iterator is any object that implements the iterator protocol. Oh boy, what's that? ๐
Let's pause for this quick recap:
- Iteration: A repetition in a sequence/loop.
- Iterable: An object having the ability to be iterated upon.
- Iterator: Any object that implements the iterator protocol.
The iterator protocol is a standard way to produce a sequence of values and potentially a return value when all values have been produced. This is achieved via an object having a special next()
method.
If this is a lot of understand right now that's completely expected. Stick with me! To explain this further, we know that there are some built-in iterables like Array
. We learned that Array
is an iterable object because its prototype includes the Symbol.iterator
key. We can see that this key is actually a method:
Okay... so what does this method return when we call it?
Interesting! It returns an iterator object that includes that special next()
method we mentioned above.
Since Array
is a built-in iterable, it implements the iterator protocol which is the way its values are iterated over. Let's check this out in code:
const zoo = ['lion', 'fox', 'lizard', 'bat'];
const iterator = zoo[Symbol.iterator](); // Get zoo's iterator
iterator.next(); // Returns {value: "lion", done: false}
iterator.next(); // Returns {value: "fox", done: false}
iterator.next(); // Returns {value: "lizard", done: false}
iterator.next(); // Returns {value: "bat", done: false}
iterator.next(); // {value: undefined, done: true}
The object returned by the iterator's next()
method will have 2 properties by default:
- done: a boolean indicating if the iterator produced any value in the iteration.
- value: the value returned by the iterator
This whole procedure using next()
and checking the return object's value
and done
properties is what's happing under the hood when you use a statement that expects iterables such as for...of
:
for (const animal of zoo) {
// Each iteration is internally calling next()
console.log(animal);
}
Now if we go back to the formal description of Symbol.iterator
's behavior, it makes a little more sense:
Whenever an object needs to be iterated (such as at the beginning of a for..of loop), its
@@iterator
method is called with no arguments, and the returned iterator is used to obtain the values to be iterated. --MDN
While this happens behind the scenes, we can manipulate Symbol.iterator
to create some custom functionality. Note that when we do this we must follow that iterator protocol by adding the next()
method that returns a object containing value
and done
properties:
const zoo = ['lion', 'pizza', 'fox', 'lizard', 'donut', 'bat'];
zoo[Symbol.iterator] = function() {
// This must return the iteration obj w/ the iterator protocol
return {
self: zoo,
step: 0,
next() {
const current = this.self[this.step];
const isDone = this.step === this.self.length;
this.step++;
if (/pizza|donut/.test(current)) {
return { value: `${current}-monster`, done: isDone };
} else {
return {value: current, done: isDone };
}
}
}
};
With the code above, we wanted to add the string -monster
to any value in the array containing "pizza" or "donut." We used the array's Symbol.iterator
property to create a custom iterator object (following the iterator protocol) to do implement this. Now when we iterator over this object we'll see that result:
for (const animal of zoo) {
console.log(animal);
}
/* Logs:
lion
pizza-monster
fox
lizard
donut-monster
bat
*/
Now we understand that Symbol.iterator
is a symbol (unique value / method) that defines the iteration behavior (or "iteration protocol") of an object. That's what Symbol.iterator
is! ๐
We mentioned before that regular objects are not iterable (they have no Symbol.iterator
property):
const albums = {
'Kill \'Em All': 1983,
'Ride the Lightning': 1984,
'Master of Puppets': 1986,
'...And Justice for All': 1988,
'Metallica': 1991
};
for (const album of albums) {
console.log(album);
} // โ TypeError: albums is not iterable
But we can make it iterable!
const albums = {
'Kill \'Em All': 1983,
'Ride the Lightning': 1984,
'Master of Puppets': 1986,
'...And Justice for All': 1988,
'Metallica': 1991,
[Symbol.iterator]: function() {
return {
step: 0,
values: Object.values(albums),
next() {
const isDone = this.step === this.values.length;
const value = this.values[this.step];
this.step++;
return { value, done: isDone };
}
};
}
};
for (const album of albums) {
console.log(album);
}
/* Logs:
1983
1984
1986
1988
1991
*/
Pretty cool, right? You have the flexibility to make your iterator as simple or as complex and you want.
Lastly, to tie up one loose-end, for...of
works differently than the other looping constructs in JavaScript. For example, for...in
will only loop over the enumerable properties of an object while for...of
will loop over any data that is iterable (implemented with the iterable protocol):
for (const album in albums) {
console.log(album);
}
/* Logs:
Kill 'Em All
Ride the Lightning
Master of Puppets
...And Justice for All
Metallica
*/
Furthermore, after we modified our zoo
array to return food monsters, a regular forEach()
array method will continue to log the regular array values since we're not using the built-in iterator:
zoo.forEach(animal => console.log(animal));
/* Logs:
lion
pizza
fox
lizard
donut
bat
*/
As you can see, there's a big difference between these looping methods but Symbol.iterator
allows for much greater control. ๐ฎ
Check out more #JSBits at my blog, jsbits-yo.com. Or follow me on Twitter.