Demystifying ES6 Iterables & Iterators

Iterable collection

Iteration is a powerful concept that allows us to process the elements of a collection or sequence one by one. The ES6 version of JavaScript introduced a standardized way to make objects iterable through the iteration protocol. As a JavaScript developer, it‘s important to understand how iteration works in ES6 and how to leverage it in your code.

In this article, we‘ll take an in-depth look at iterables and iterators in ES6. We‘ll start by defining these core concepts and explaining the iteration protocol that enables objects to be iterable. Then we‘ll explore some of the built-in iterable data sources in JavaScript and see how to consume their data using iteration. Finally, we‘ll dive into implementing our own custom iterables and iterators.

By the end of this guide, you‘ll have a solid grasp of ES6 iteration and be able to harness its capabilities in your JavaScript projects. Let‘s jump in and demystify iterables and iterators!

The Building Blocks of Iteration

Before we get into the details, let‘s establish the core terminology and ideas behind ES6 iteration:

Iterable – An object that implements the iteration protocol, which allows it to be iterated over. In practice, this means the object (or its prototype chain) must have a property with a Symbol.iterator key, which is a zero arguments function that returns an iterator object.

Iterator – An object returned by the Symbol.iterator function, that conforms to the iterator protocol. It must have a next() method that returns an object with two properties:

  • value: The current value in the iteration sequence
  • done: A boolean indicating if the iteration is complete

When an object is iterable, its data can be consumed by various ES6 language constructs and methods that expect iterables. These include:

  • for…of loops
  • Spread syntax (…)
  • yield*
  • Destructuring assignment
  • Array.from()
  • Maps and Sets

There are two sides to the iteration story:

Data sources – Objects that hold data and need to provide a way to access it in a sequential manner. To do this, they implement the iterable protocol so that they can be iterated over by consumers.

Data consumers – Language constructs, functions, and operators that access data by iterating over an iterable object. They expect an iterable, and use the iterator it provides to retrieve the data.

So in summary, an iterable is able to provide an iterator to allow others to iterate over it and access its data. Let‘s see what this looks like in practice.

Built-in Iterables

Many of the built-in data structures in JavaScript are iterable, allowing us to easily loop over their contents. These include:

Arrays
Arrays are the most commonly used iterable objects. When you use a for…of loop on an array, you can access each element in sequence.

const numbers = [1, 2, 3, 4, 5];

for (const num of numbers) {
  console.log(num);
}
// Output: 1 2 3 4 5

Strings
Strings are also iterable, and for…of will iterate over each character (Unicode code points) in the string.

const message = "hello";

for (const char of message) {
  console.log(char);
}
// Output: h e l l o

Maps
The Map object is an iterable of key-value pairs. Each iteration returns an array of [key, value] for each entry in the map.

const map = new Map();
map.set(‘a‘, 1);
map.set(‘b‘, 2);  

for (const [key, value] of map) {
  console.log(`${key}: ${value}`);
}
// Output: a: 1  b: 2

Sets
The Set object is an iterable of unique values. Each iteration returns the next value in the set.

const set = new Set([1, 2, 3, 3, 4]);

for (const value of set) {
  console.log(value);
}  
// Output: 1 2 3 4

These built-in iterables make it very convenient to sequentially access data from different types of collections. But what about plain JavaScript objects? Let‘s find out.

Why Plain Objects are Not Iterable

While many of JavaScript‘s built-in collection types are iterable by default, plain objects are a notable exception. But why is that?

The reason is that there‘s an ambiguity around what it means to iterate over an object. Objects have two kinds of properties:

  1. "Own" properties that store the object‘s state and data
  2. Prototypal properties that define the object‘s behavior (methods)

If objects were iterable, it would be unclear whether we should iterate over just the "own" data properties, or include methods and inherited properties from the prototype chain as well.

Here‘s an example to illustrate:

const obj = {
  a: 1, 
  b: 2,
  toString() {
    return `${this.a}, ${this.b}`;
  }
};

Should iterating over obj include the toString method or not? There‘s no clear answer.

To avoid this ambiguity, the designers of ES6 made it so plain object are not iterable by default. Iteration is restricted to data collections where it‘s clear what sequence of values should be produced.

That said, it‘s still possible to define our own iterable objects by implementing the iterator protocol. Let‘s see how that works.

Implementing Iterables

To make an object iterable, we need to define a method on it (or on its prototype) with the Symbol.iterator key. This method should return an iterator object with a next() method that produces the sequence of values.

Here‘s an example of a custom range iterable that produces a sequence of numbers:

const range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    return {
      current: this.from,
      last: this.to,

      next() {
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

We define the Symbol.iterator method on the range object to make it iterable. This method returns the iterator object with the next() method.

Inside next(), we check if the current value is less than or equal to the last value. If so, we return an object with done: false and the current value. We then increment current for the next iteration.

Once current passes last, we return done: true to signal that iteration is complete.

We can now use a for…of loop to iterate over the range:

for (const num of range) {
  console.log(num);
}
// Output: 1 2 3 4 5  

The loop will keep calling next() on the iterator until done becomes true.

Iterators as Iterables

In the previous example, we returned a separate iterator object from the Symbol.iterator method. However, we can also make iterators themselves iterable by having Symbol.iterator return the this keyword.

const infiniteRange = {
  [Symbol.iterator]() {
    let current = 1;

    return {
      [Symbol.iterator]() { return this; },

      next() {
        const value = current++;
        return { done: false, value };
      }
    };
  }
};

Here the iterator returned by Symbol.iterator has its own Symbol.iterator method that just returns itself (this). This makes the iterator iterable, allowing it to be used in for…of loops directly.

However, since next() always returns done: false, this will create an infinite loop if we try to iterate over all values. Iterators that never end can be useful in certain scenarios like representing infinite sequences, but we need to be careful to only take a finite portion of their values.

Generator Functions

While it‘s possible to define iterators manually as we‘ve shown, generator functions give us an easier way to create iterator objects.

Generator functions are defined with function* syntax and use the yield keyword to produce a sequence of values. Each yield expression defines the next value to be returned by the iterator‘s next() method.

Here‘s the range iterable implemented as a generator function:

function* range(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

for (const num of range(1, 5)) {
  console.log(num);
}
// Output: 1 2 3 4 5

When a generator function is called, it returns a special generator object that is both an iterator and an iterable. We can iterate over it with for…of, or call next() on it directly to access each yielded value.

Generators give us a very concise and readable way to define iterators, and are the most common way to create custom iterables in ES6 code.

Iterator Methods

In addition to the next() method, ES6 defines two optional methods that iterators can implement:

return() – If defined, this will be called automatically if iteration ends early (e.g. via break, throw, or return). It gives the iterator a chance to perform any cleanup after an abrupt exit.

throw() – If defined, this will be called if an exception is thrown during iteration, giving the iterator a chance to handle it. This is rarely used.

Here‘s an example of an iterator that uses return() for cleanup:

const closeableRange = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    return {
      current: this.from,
      last: this.to,

      next() {
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      },

      return() {
        console.log(‘Closing iterator‘);
        return { done: true };  
      }
    };
  }
};

for (const num of closeableRange) {
  console.log(num);
  if (num === 3) {
    break;
  }  
}
// Output: 
// 1
// 2 
// 3
// Closing iterator

Here return() will be called when we break out of the loop, allowing the iterator to log a message. This can be useful for cleaning up resources held by the iterator.

Conclusion

Iterables and iterators are a powerful feature in ES6 that provide a standardized way to loop over the elements of different data sources. By understanding the iteration protocol and how to use and implement iterables, you can write more expressive and flexible JavaScript code.

The key points to remember are:

  • An iterable is an object that provides an iterator through the Symbol.iterator method.
  • An iterator is an object with a next() method that returns {value, done} objects.
  • Many built-in collections like arrays, strings, maps and sets are iterable.
  • Plain objects are not iterable by default to avoid ambiguity over which properties to include.
  • We can make our own iterables by defining a Symbol.iterator method that returns an iterator object.
  • Generator functions provide concise syntax for defining iterators.
  • Iterators can optionally define return() and throw() methods for resource cleanup.

I hope this in-depth look at iterables and iterators in ES6 has demystified the topic and equipped you to leverage their power in your JavaScript projects. Happy iterating!

Similar Posts