JS Bits with Bill
JS Bits with Bill

JS Bits with Bill

Symbols Are Your Friend Part III: Symbol.iterator

Symbols Are Your Friend Part III: Symbol.iterator

JS Bits with Bill's photo
JS Bits with Bill

Published on Oct 20, 2020

6 min read

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:

  1. done: a boolean indicating if the iterator produced any value in the iteration.
  2. 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.

 
Share this