Iterating over data collections using traditional loops can quickly become cumbersome and slow, especially when dealing with massive amounts of data.

JavaScript Generators and Iterators provide a solution for efficiently iterating over large data collections. Using them, you can control the iteration flow, yield values one at a time, and pause and resume the iteration process.

Here you will cover the basics and internals of a JavaScript iterator and how you can generate an iterator manually and using a generator.

JavaScript Iterators

An iterator is a JavaScript object that implements the iterator protocol. These objects do so by having a next method. This method returns an object that implements the IteratorResult interface.

The IteratorResult interface comprises two properties: done and value. The done property is a boolean that returns false if the iterator can produce the next value in its sequence or true if the iterator has completed its sequence.

The value property is a JavaScript value returned by the iterator during its sequence. When an iterator completes its sequence (when done === true), this property returns undefined.

As the name implies, iterators allow you to “iterate” over JavaScript objects such as arrays or maps. This behavior is possible due to the iterable protocol.

In JavaScript, the iterable protocol is a standard way of defining objects that you can iterate over, such as in a for...of loop.

For example:

 const fruits = ["Banana", "Mango", "Apple", "Grapes"];

for (const iterator of fruits) {
  console.log(iterator);
}

/*
Banana
Mango
Apple
Grapes
*/

This example iterates over the fruits array using a for...of loop. In each iteration it logs the current value to the console. This is possible because arrays are iterable.

Some JavaScript types, such as Arrays, Strings, Sets, and Maps, are built-in iterables because they (or one of the objects up their prototype chain) implement an @@iterator method.

Other types, such as Objects, are not iterable by default.

For example:

 const iterObject = {
  cars: ["Tesla", "BMW", "Toyota"],
  animals: ["Cat", "Dog", "Hamster"],
  food: ["Burgers", "Pizza", "Pasta"],
};

for (const iterator of iterObject) {
  console.log(iterator);
}

// TypeError: iterObject is not iterable

This example demonstrates what happens when you try to iterate over an object that is not iterable.

Making an Object Iterable

To make an object iterable, you have to implement a Symbol.iterator method on the object. To become iterable, this method must return an object that implements the IteratorResult interface.

The Symbol.iterator symbol serves the same purpose as @@iterator and can be used interchangeably in “specification” but not in code as @@iterator is not valid JavaScript syntax.

The code blocks below provide an example of how to make an object iterable using the iterObject.

First, add the Symbol.iterator method to iterObject using a function declaration.

Like so:

 iterObject[Symbol.iterator] = function () {
  // Subsequent code blocks go here...
}

Next, you'll need to access all the keys in the object you want to make iterable. You can access the keys using the Object.keys method, which returns an array of the enumerable properties of an object. To return an array of iterObject’s keys, pass the this keyword as an argument to Object.keys.

For example:

 let objProperties = Object.keys(this)

Access to this array will allow you to define the iteration behavior of the object.

Next, you need to keep track of the object’s iterations. You can achieve this using counter variables.

For example:

 let propertyIndex = 0;
let childIndex = 0;

You will use the first counter variable to keep track of the object properties and the second to keep track of the property’s children.

Next, you'll need to implement and return the next method.

Like so:

 return {
  next() {
    // Subsequent code blocks go here...
  }
}

Inside the next method, you'll need to handle an edge case that occurs when the entire object has been iterated over. To handle the edge case, you have to return an object with the value set to undefined and done set to true.

If this case is not handled, trying to iterate over the object will result in an infinite loop.

Here’s how to handle the edge case:

 if (propertyIndex > objProperties.length - 1) {
  return {
    value: undefined,
    done: true,
  };
}

Next, you'll need to access the object properties and their child elements using the counter variables you declared earlier.

Like so:

 // Accessing parent and child properties
const properties = this[objProperties[propertyIndex]];
    
const property = properties[childIndex];

Next, you need to implement some logic for incrementing the counter variables. The logic should reset the childIndex when no more elements exist in a property’s array and move to the next property in the object. Additionally, it should increment childIndex, if there are still elements in the current property’s array.

For example:

 // Index incrementing logic
if (childIndex >= properties.length - 1) {
  // if there are no more elements in the child array
  // reset child index
  childIndex = 0;
        
  // Move to the next property
  propertyIndex++;
} else {
  // Move to the next element in the child array
  childIndex++
}

Finally, return an object with the done property set to false and the value property set to the current child element in the iteration.

For example:

 return {
  done: false,
  value: property,
};

Your completed Symbol.iterator function should be similar to the code block below:

 iterObject[Symbol.iterator] = function () {
  const objProperties = Object.keys(this);
  let propertyIndex = 0;
  let childIndex = 0;

  return {
    next: () => {
      //Handling edge case
      if (propertyIndex > objProperties.length - 1) {
        return {
          value: undefined,
          done: true,
        };
      }

      // Accessing parent and child properties
      const properties = this[objProperties[propertyIndex]];
    
      const property = properties[childIndex];

      // Index incrementing logic
      if (childIndex >= properties.length - 1) {
        // if there are no more elements in the child array
        // reset child index
        childIndex = 0;
        
        // Move to the next property
        propertyIndex++;
      } else {
        // Move to the next element in the child array
        childIndex++
      }

      return {
        done: false,
        value: property,
      };
    },
  };
};

Running a for...of loop on iterObject after this implementation will not throw an error as it implements a Symbol.iterator method.

Manually implementing iterators, as we did above, is not recommended as it is very error-prone, and the logic can be hard to manage.

JavaScript Generators

A JavaScript generator is a function that you can pause and resume its execution at any point. This behavior allows it to produce a sequence of values over time.

A generator function, which is a function that returns a Generator, provides an alternative to creating iterators.

You can create a generator function the same way you'd create a function declaration in JavaScript. The only difference is that you must append an asterisk (*) to the function keyword.

For example:

 function* example () {
  return "Generator"
}

When you call a normal function in JavaScript, it returns the value specified by its return keyword or undefined otherwise. But a generator function does not return any value immediately. It returns a Generator object, which you can assign to a variable.

To access the current value of the iterator, call the next method on the Generator object.

For example:

 const gen = example();

console.log(gen.next()); // { value: 'Generator', done: true }

In the example above, the value property came from a return keyword, effectively terminating the generator. This behavior is generally undesirable with generator functions, as what distinguishes them from normal functions is the ability to pause and restart execution.

The yield Keyword

The yield keyword provides a way to iterate through values in generators by pausing the execution of a generator function and returning the value that follows it.

For example:

 function* example() {
  yield "Model S"
  yield "Model X"
  yield "Cyber Truck"

  return "Tesla"
}

const gen = example();

console.log(gen.next()); // { value: 'Model S', done: false }

In the example above, when the next method gets called on the example generator, it will pause every time it encounters the yield keyword. The done property will also be set to false until it encounters a return keyword.

Calling the next method multiple times on the example generator to demonstrate this, you'll have the following as your output.

 console.log(gen.next()); // { value: 'Model X', done: false }
console.log(gen.next()); // { value: 'Cyber Truck', done: false }
console.log(gen.next()); // { value: 'Tesla', done: true }

console.log(gen.next()); // { value: undefined, done: true }

You can also iterate over a Generator object using the for...of loop.

For example:

 for (const iterator of gen) {
  console.log(iterator);
}

/*
Model S
Model X
Cyber Truck
*/

Using Iterators and Generators

Although iterators and generators may seem like abstract concepts, they are not. They can be helpful when working with infinite data streams and data collections. You can also use them to create unique identifiers. State management libraries such as MobX-State-Tree (MST) also use them under the hood.