Skip to Content
Course content

206: Typing Async Iterators

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

Async iterators are one of those features that feel like magic when they work, but the moment you try to implement a custom one without a generator function, the TypeScript types start fighting you. I see this most often when developers try to wrap a legacy callback-based API or a paginated REST endpoint into something that can be used with a for await...of loop.

The "Implicit Any" Iterator Trap

Imagine you're building a MessageQueue reader. You want to be able to stream messages from a server one by one. You might start writing something like this:

interface Message {
  id: string;
  text: string;
}

class MessageQueue implements AsyncIterable<Message> {
  private cursor = 0;
  private messages = ['Hello', 'World', 'TypeScript is cool'];

  async *[Symbol.asyncIterator]() {
    while (this.cursor < this.messages.length) {
      yield { id: `${this.cursor}`, text: this.messages[this.cursor++] };
    }
  }
}

// This works because of the async generator (*) syntax...
// But what happens when you can't use a generator?

Async generators (the async * syntax) are great because TypeScript handles the heavy lifting for you. But in the real world, you'll eventually encounter a situation where you have to implement the iterator manually—perhaps because you're integrating with a complex state machine or a third-party library that doesn't support generators. When you do that, you'll likely hit a wall like this:

class ManualMessageQueue implements AsyncIterable<Message> {
  private cursor = 0;
  private messages = ['Hello', 'World', 'TypeScript is cool'];

  [Symbol.asyncIterator]() {
    return {
      async next() {
        if (this.cursor < this.messages.length) {
          return { value: { id: '1', text: '...' }, done: false };
        }
        return { done: true };
      }
    };
  }
}

If you hover over that return object, you'll notice TypeScript is complaining. It's telling you that the object you're returning doesn't satisfy the AsyncIterator<Message> interface. Specifically, it's confused about the return type of next(). You've returned a promise that resolves to an object, but the interface is very picky about the shape of that result.

Aligning with AsyncIteratorResult

The core of the problem is that AsyncIterable is just a wrapper that tells TypeScript: "this object has a method that returns an AsyncIterator." The AsyncIterator itself is what actually does the work via the next() method.

To fix the typing, you need to be explicit about the IteratorResult<T>. The next() method must return a Promise<IteratorResult<T>>. If you leave it implicit, TypeScript often struggles to infer that the done: true case is compatible with the done: false case when wrapped in a Promise.

Here is how we clean that up. I prefer defining the iterator return type explicitly to avoid any ambiguity:

class ManualMessageQueue implements AsyncIterable<Message> {
  private cursor = 0;
  private messages = ['Hello', 'World', 'TypeScript is cool'];

  // We explicitly tell TS that this returns an AsyncIterator
  [Symbol.asyncIterator](): AsyncIterator<Message> {
    return {
      async next(): Promise<IteratorResult<Message>> {
        if (this.cursor < this.messages.length) {
          return { 
            value: { id: `${this.cursor}`, text: this.messages[this.cursor++] }, 
            done: false 
          };
        }
        // We must return the object with done: true to signal the end of the loop
        return { value: undefined, done: true };
      }
    };
  }
}

async function run() {
  const queue = new ManualMessageQueue();
  for await (const msg of queue) {
    console.log(msg.text);
  }
}

Notice two key things here:

  • The return type of [Symbol.asyncIterator] is AsyncIterator<Message>.
  • The return type of next() is Promise<IteratorResult<Message>>.

I'll give you a pro tip: if you find yourself writing a lot of manual AsyncIterator implementations, you're probably doing it the hard way. Always check if an async * generator can do the job first. But when it can't, knowing that IteratorResult is the bridge between your logic and the for await...of loop is what saves you from a debugging nightmare.




📋 Practical Task

Build a Paginated API Streamer

You are tasked with creating a UserStreamer class that implements AsyncIterable<User>. Instead of using an async generator, you must implement the AsyncIterator manually to practice the typing we just covered.

Requirements:

  • Define a User interface with id: number and username: string.
  • Create a UserStreamer class that holds an internal currentPage counter.
  • Implement [Symbol.asyncIterator]() so that it returns an object with a next() method.
  • The next() method should simulate an API call (use a Promise with setTimeout or async/await).
  • The streamer should stop (return done: true) once currentPage reaches 3.
  • Ensure all return types are explicitly typed using AsyncIterator and IteratorResult.
interface User {
  id: number;
  username: string;
}

class UserStreamer implements AsyncIterable<User> {
  private currentPage = 1;

  // Your implementation here
}

// Test your code:
async function test() {
  const streamer = new UserStreamer();
  for await (const user of streamer) {
    console.log(`Fetched user: ${user.username}`);
  }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.