Skip to Content
Course content

99: Typing Iterators and Iterables

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a data-processing library. A colleague had built this QueryResults class that wrapped a large array of database records. To get the data, you had to call results.getItems() and then loop over that array. It wasn't a dealbreaker, but it felt clunky. I suggested making the QueryResults class itself iterable so we could just use for (const row of results). He tried it, but he didn't type the iterator correctly. Suddenly, the row variable inside the loop became any. We lost all our type safety for the most critical part of the data pipeline, and we didn't even notice until a runtime error hit production because someone assumed a field existed that didn't.

Most of the time, you're just iterating over arrays or maps, and TypeScript handles that magic behind the scenes. But when you start building custom collections or complex data streams, you need to tell TypeScript exactly what's coming out of your iterator to keep those type guarantees intact.

Implementing the Iterable Protocol

To make an object iterable in TypeScript, you have to implement the Iterable<T> interface. This basically tells the compiler, "This object has a method that can produce an iterator." That method is always [Symbol.iterator]. If you forget this, or if you type it as a generic function, you'll end up with the any problem I mentioned earlier.

Take a look at this ProductCatalog example. Instead of exposing the internal array, we make the catalog itself iterable:

interface Product {
  id: string;
  name: string;
  price: number;
}

class ProductCatalog implements Iterable<Product> {
  private products: Product[] = [
    { id: '1', name: 'Mechanical Keyboard', price: 150 },
    { id: '2', name: 'Ergonomic Mouse', price: 80 },
    { id: '3', name: '4K Monitor', price: 400 },
  ];

  // This is the magic method that allows for...of loops
  [Symbol.iterator](): Iterator<Product> {
    let index = 0;
    const products = this.products;

    return {
      next(): IteratorResult<Product> {
        if (index < products.length) {
          return { value: products[index++], done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
}

const catalog = new ProductCatalog();
for (const product of catalog) {
  console.log(product.name); // TypeScript knows 'product' is a Product
}

Distinguishing the Iterable from the Iterator

It's easy to confuse Iterable and Iterator, but they are distinct roles. I like to think of the Iterable as the "book" and the Iterator as the "bookmark." The book is the thing you can read, but the bookmark is the object that actually knows where you are in the process and how to get to the next page.

  • Iterable<T>: An object that has a [Symbol.iterator] method. Its only job is to return an iterator.
  • Iterator<T>: An object with a next() method that returns an IteratorResult<T> (which is just a fancy name for { value: T, done: boolean }).

If you're using generator functions (the ones with the *), TypeScript simplifies this immensely because generators automatically implement both interfaces. If you find yourself writing the next() logic manually—like I did in the ProductCatalog example—you're doing it the hard way. I usually only do that when I need extremely fine-grained control over the iteration state or when I'm avoiding generators for specific performance reasons in a tight loop.




📋 Practical Task

Building a Type-Safe Circular Buffer Iterator

You are tasked with creating a CircularBuffer class that stores a fixed number of integers. When the buffer is full, new elements overwrite the oldest ones. To make this buffer useful for other developers, you need to implement the Iterable<number> interface so the buffer can be used directly in a for...of loop.

Requirements:

  • Create a class CircularBuffer that accepts a capacity: number in its constructor.
  • Implement a push(value: number) method to add items to the buffer.
  • Implement [Symbol.iterator]() so that iterating over the buffer yields the elements in the order they were added (oldest to newest), regardless of where they are stored internally in the array.
  • Ensure that the loop variable in a for...of loop is strictly typed as a number.

Starter Code:

class CircularBuffer implements Iterable<number> {
  private buffer: number[] = [];
  private pointer = 0;

  constructor(private capacity: number) {}

  push(value: number) {
    // Your implementation here
  }

  [Symbol.iterator](): Iterator<number> {
    // Your implementation here
  }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.