Efficient Data Handling: Understanding JavaScript Iterators and Generators

Siddhartha SSiddhartha S
6 min read

Introduction

Looping is one of the essential features of any programming language, and JavaScript is no exception. Coming from a C# background, I am familiar with the IEnumerable interface, which allows a class to be iterable. JavaScript provides a similar feature called Iterable, and when combined with generator function, it offers powerful iteration capabilities.

Background

JavaScript supports various looping mechanisms, including for...in and for...of, in addition to the standard for loop present in almost every programming language.

The for...in loop enables us to iterate through the keys of an object:

const obj = { a: 1, b: 2, c: 3 };
for (let prop in obj) {
    console.log(prop + ': ' + obj[prop]);
}

On the other hand, the for...of loop lets us iterate through a collection of objects:

const arr = [1, 2, 3, 4, 5];
for (let value of arr) {
    console.log(value);
}

This led me to wonder what allows the array object to function within a for...of loop. The answer lies in the Symbol.iterator. When we combine it with lazy-loading generator functions, we create powerful encapsulations over complex executions. Let's explore this further in the following sections.

Iterator

Symbol.iterator is a special function that, when defined on a prototype, grants looping capabilities to that object. In other words, we can use a for...of loop to iterate over that symbol. Here’s a sample code snippet to help you visualize this:

class FirstNNumbers {
    constructor(N) {
        this.N = N;
    }

    [Symbol.iterator]() {
       let count = 0;
       const limit = this.N;

       return {
           next: () => {
               count++;
               if (count <= limit) {
                   return { value: count, done: false };
               } else {
                   return { done: true };
               }
            }
        };
    }
}

// Example Usage
const firstFiveNumbers = new FirstNNumbers(5);
for (const num of firstFiveNumbers) {
    console.log(num); // Outputs: 1, 2, 3, 4, 5
}

The example above demonstrates a JavaScript class (which I prefer). However, if you favor a functional programming style, you can achieve the same functionality with functional code:

const FirstNNumbers = function(N) {
    this.N = N; };


FirstNNumbers.prototype[Symbol.iterator] = function() {
    let count = 0;
    const limit = this.N;

    return {
        next: () => {
            if (count < limit) {
                count++;
                return { value: count, done: false }; r
            } else {
                return { done: true };
            }
        }
    };
};

// Example Usage
const firstFiveNumbers = new FirstNNumbers(5);
for (const num of firstFiveNumbers) {
    console.log(num); // Outputs: 1, 2, 3, 4, 5
}

If you're a TypeScript enthusiast, you can certainly implement this in TypeScript as well, reaping the benefits of generics.

For more information, refer to the TypeScript documentation on iterators and generators: TypeScript Iterators and Generators.

Generator

Now that we've explored iterators, let's look into generators. Generator functions in JavaScript allow us to return a sequence that is lazy-loaded or evaluated later. This feature utilizes the yield keyword, enabling us to create a stream that can be paused and resumed. The function definition must include an asterisk (*). Here’s how a generator function looks:

function* numberGenerator() {
    let count = 1;
    while (count <= 3) {
        yield count; // Pause here and return the current count
        count++;
    }
}

// Example Usage
const generator = numberGenerator();

console.log(generator.next()); // { value: 1; first yield will be called here. }
console.log(generator.next()); // { value: 2, second yield will be called here. }
console.log(generator.next()); // { value: 3, third yield will be called here. }
console.log(generator.next()); // { done: true }

Async Please?

Generator functions can also handle asynchronous functionalities, making them extremely useful. This allows us to evaluate each sequence item for every yield call, leading to efficient memory usage and performance. Running the following example code using Node.js will demonstrate a slight delay between console prints:

async function* fetchDataGenerator(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    yield data;
  }
}

const apiUrls = [
  'https://jsonplaceholder.typicode.com/posts/1',
  'https://jsonplaceholder.typicode.com/posts/2',
  'https://jsonplaceholder.typicode.com/posts/3',
];

(async () => {
  const dataGenerator = fetchDataGenerator(apiUrls);


  for await (const data of dataGenerator) {
    console.log(data);
  }
})();

The delay occurs because the yield keyword returns a value as soon as it becomes available, allowing the calling function to print it. During the next iteration, the fetchData generator retains its state, knowing exactly where to resume. It then fetches the next result and returns it immediately.

Mixing Both

The Symbol.iterator can be combined with asynchronous generator functions to create a powerful iteration abstraction within an object. Consider the following code:

//Dummy method to simulate some API call or time taking task.
const fetchItems = async (baseValue) => {
  await new Promise((resolve) => setTimeout(resolve, 500));
  const dummyArray = [];
  const startIndex = baseValue * 10;
  for (let i = baseValue; i < baseValue + 5; i++) {
    dummyArray.push({
      prop1: `Prop1: ${startIndex}`,
      prop2: `Prop 2:${startIndex * 10}`,
    });
  }
  return dummyArray;
};

class ItemEnumerable {
  constructor() {
    this.currentCounter = 1;
  }

  async *[Symbol.asyncIterator]() {
    while (true) {
      const users = await fetchItems(this.currentCounter);
      if (users.length === 0) {
        break;
      }
      yield* users;
      this.currentCounter++;

      if (this.currentCounter > 3) {
        break;
      }
    }
  }
}

async function loadItems() {
  const enumerable = new ItemEnumerable();
  for await (const item of enumerable) {
    console.log(`Item: ${item.prop1} & ${item.prop2}`);
  }
}

loadItems();

Explanation of the Code:

In the code above:

  • The ItemEnumerable class utilises the asynchronous generator in its Symbol.asyncIterator implementation.

  • The instance maintains an internal counter that is used to simulate API calls.

  • The fetchItems function simulates fetching five items for each call.

  • Once the API call returns, those five items are yielded immediately to the calling function (i.e., the for await...of loop) and printed.

  • The iterator's internal state allows it to perform three iterations, resulting in a total of 15 items returned: five items for each of the three iterations of the internal counter (currentCounter).

  • When executing this code, you'll notice a slight delay after every five items due to the simulated API call.

Output Example:

Item: Prop1: 10 & Prop 2:100
Item: Prop1: 10 & Prop 2:100
Item: Prop1: 10 & Prop 2:100
Item: Prop1: 10 & Prop 2:100
Item: Prop1: 10 & Prop 2:100
Item: Prop1: 20 & Prop 2:200
Item: Prop1: 20 & Prop 2:200
Item: Prop1: 20 & Prop 2:200
Item: Prop1: 20 & Prop 2:200
Item: Prop1: 20 & Prop 2:200
Item: Prop1: 30 & Prop 2:300
Item: Prop1: 30 & Prop 2:300
Item: Prop1: 30 & Prop 2:300
Item: Prop1: 30 & Prop 2:300
Item: Prop1: 30 & Prop 2:300

Use Cases:

Combining the Symbol.iterator and async generator functions can be beneficial for various applications, such as:

  • Streaming Data: Handling IoT sensor data, social media feeds, log files, etc.

  • Database Queries: Efficiently fetching and chunking data from databases.

  • Recursive Data Structures: Flattening complex structures using the combined functionality of iterators and async generators.

Conclusion

In this article, we explored the Symbol.iterator function and asynchronous generator functions. We demonstrated how they provide a fantastic abstraction for iterating over data, both synchronously and asynchronously. Additionally, we highlighted several practical use cases where this pattern can be advantageous.

0
Subscribe to my newsletter

Read articles from Siddhartha S directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Siddhartha S
Siddhartha S

With over 18 years of experience in IT, I specialize in designing and building powerful, scalable solutions using a wide range of technologies like JavaScript, .NET, C#, React, Next.js, Golang, AWS, Networking, Databases, DevOps, Kubernetes, and Docker. My career has taken me through various industries, including Manufacturing and Media, but for the last 10 years, I’ve focused on delivering cutting-edge solutions in the Finance sector. As an application architect, I combine cloud expertise with a deep understanding of systems to create solutions that are not only built for today but prepared for tomorrow. My diverse technical background allows me to connect development and infrastructure seamlessly, ensuring businesses can innovate and scale effectively. I’m passionate about creating architectures that are secure, resilient, and efficient—solutions that help businesses turn ideas into reality while staying future-ready.