ES2018 introduces several new JavaScript language features that are great for Node.js developers. Promise.prototype.finally() is the most important new feature, but I think async iterators are a close second. In this article, I'll describe what you need to start using async iterators in Node.js. I'll also provide an example of how to use async iterators with Mongoose cursors.

Your First Async Iterator

Async iterators are natively supported in Node.js 10.x. If you're using Node.js 8.x or 9.x, you need to use Node.js' --harmony_async_iteration flag. Async iterators are not supported in Node.js 6.x or 7.x, so if you're on an older version you need to upgrade Node.js to use async iterators.

$ node --version
v9.9.0
$ node test.js
/home/node/test.js:7
  for await (const x of gen()) {
      ^^^^^

SyntaxError: Unexpected reserved word
    at new Script (vm.js:51:7)
    at createScript (vm.js:136:10)
    at Object.runInThisContext (vm.js:197:10)
    at Module._compile (module.js:613:28)
    at Object.Module._extensions..js (module.js:660:10)
    at Module.load (module.js:561:32)
    at tryModuleLoad (module.js:501:12)
    at Function.Module._load (module.js:493:3)
    at Function.Module.runMain (module.js:690:10)
    at startup (bootstrap_node.js:194:16)
$
$ node --harmony_async_iteration test.js
works!
$

Now that flags and versions are out of the way, let's review what an iterator is in JavaScript. An iterator is an object that exposes a next() function which returns an object { value, done }. The value property is the next value in the sequence, and the done property is a boolean that is true if there are no more values in the sequence. For example, below is an iterator that loops over the values in an array.

const nums = [1, 2, 3];

let index = 0;
const iterator = {
  next: () => {
    if (index >= nums.length) {
      return { done: true };
    }
    const value = iterator[index++];
    return { value, done: false };
  }
};

By itself, an iterator isn't very useful. In order to use an iterator with a for/of loop, you need an iterable. An iterable is an object with a Symbol.iterator function that returns an iterator. Below is a simple iterable that just returns the above iterator.

const iterable = {
  [Symbol.iterator]: () => iterator
};
for (const v of iterable) {
  console.log(v); // Prints "1", "2", "3"
}

ES2018 introduces 2 related concepts: async iterators and async iterables. The difference between an async iterator and a conventional iterator is that, instead of returning a plain object { value, done }, an async iterator returns a promise that fulfills to { value, done }. Similarly, an async iterable is an object with Symbol.asyncIterator function that returns an async iterator. Below is an example of creating an async iterator and an async iterable.

const nums = [1, 2, 3];

let index = 0;
const asyncIterator = {
  next: () => {
    if (index >= nums.length) {
      // A conventional iterator would return a `{ done: true }`
      // object. An async iterator returns a promise that resolves
      // to `{ done: true }`
      return Promise.resolve({ done: true });
    }
    const value = nums[index++];
    return Promise.resolve({ value, done: false });
  }
};

const asyncIterable = {
  // Note that async iterables use `Symbol.asyncIterator`, **not**
  // `Symbol.iterator`.
  [Symbol.asyncIterator]: () => asyncIterator
};

Asynchronous Iteration

You can use a for/of loop to loop through an iterable. However, for/of won't work with async iterables because value and done aren't computed synchronously. If you're a regular reader of this blog, you might have seen some async/await design patterns for this. With async/await, iterating through an async iterator is doable:

const asyncIterable = {
  [Symbol.asyncIterator]: () => asyncIterator
};

main().catch(error => console.error(error.stack));

async function main() {
  // To be concise, just get the `next()` function
  const { next } = asyncIterable[Symbol.asyncIterator]();

  // Use a `for` loop with `await` to exhaust the iterable. Once
  // `next()` resolves to a promise with `done: true`, exit the
  // loop.
  for (let { value, done } = await next(); !done; { value, done } = await next()) {
    console.log(value); // Prints "1", "2", "3"
  }
}

The above approach works in Node.js 8.x and 9.x without a flag, but also isn't as clean as it could be. The main crux of the async iterator spec is the for/await/of loop, which lets you loop over an async iterator using async/await. Like regular await, you can only use for/await/of in an async function. Below is an example of using for/await/of to loop through asyncIterable.

const asyncIterable = {
  [Symbol.asyncIterator]: () => asyncIterator
};

main().catch(error => console.error(error.stack));

async function main() {
  for await (const value of asyncIterable) {
    console.log(value);
  }
}

The for await syntax is much more concise than a conventional async/await for loop. Because it behaves like a for/of loop, for/await/of is easier to understand because it automatically checks for the end of the iterator on its own.

What happens if your async iterator returns a rejected promise? Much like how await on a rejected promise throws an error that you can try/catch, for/await/of throws a catchable error if the async iterator returns a promise that rejects.

const asyncIterator = {
  next: () => Promise.reject(new Error('Oops!'))
};

const asyncIterable = {
  [Symbol.asyncIterator]: () => asyncIterator
};

main().catch(error => console.error(error.stack));

async function main() {
  try {
    for await (const value of asyncIterable) {}
  } catch (error) {
    // Prints "Caught: Oops!"
    console.log('Caught:', error.message);
  }
}

Now that you've seen async iterators in action on a toy example, let's take a look at async iterators in a more real world example: Mongoose cursors.

Using for/await/of with Mongoose Cursors

A Mongoose query cursor is an object with a next() function that retrieves the next document in a query result. Cursors are useful for iterating through huge data sets because they load data from MongoDB in batches, so you never have all the data in your server's memory at once. Below is an example of iterating through a Mongoose cursor using a conventional async/await for loop.

const mongoose = require('mongoose');

main().catch(error => console.error(error.stack));

async function main() {
  await mongoose.connect('mongodb://localhost:27017/test');

  const Movie = mongoose.model('Movie', new mongoose.Schema({
    name: String
  }));

  await Movie.deleteMany({});
  await Movie.insertMany([
    { name: 'Enter the Dragon' },
    { name: 'Ip Man' },
    { name: 'Kickboxer' }
  ]);

  const cursor = Movie.find().cursor();
  // Use `next()` and `await` to exhaust the cursor
  for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) {
    // Prints "Enter the Dragon', "Ip Man", "Kickboxer"
    console.log(doc.name);
  }
}

While Mongoose cursors have a next() function, they are not currently async iterators or async iterables. Unlike an async iterator, a Mongoose cursor returns null to signify the end of the cursor, not { done: true }. In order to use for/await/of with Mongoose cursors, you need to do a little work to transform a Mongoose cursor into an iterator and wrap it in an async iterable.

const cursor = Movie.find().cursor();

// Wrap the cursor in an async iterable using `then()` to
// transform the result of `cursor.next()` into properly
// formatted async iterator output
const asyncIterable = {
  [Symbol.asyncIterator]: () => ({
    next: () => cursor.next().then(value => ({
      value,
      done: value == null
    }))
  })
};

// Use for/await/of to loop through the async iterable
for await (const doc of asyncIterable) {
  // Prints "Enter the Dragon', "Ip Man", "Kickboxer"
  console.log(doc.name);
}

Having to wrap a cursor to work with for/await/of is clunky, which is why Mongoose has an issue open to support async iteration with query cursors. Follow this GitHub issue or Mongoose on Twitter for updates.

Moving On

Async iterators and for/await/of loops are one of the most exciting new features in ES2018. Async iteration lets you use for/of with async/await to make async loops syntactically pristine. Make sure you upgrade to Node.js 10.x to start using for/await/of, and look out for Mongoose and the MongoDB driver to support async iteration in the near future.

Looking to become fluent in async/await? My new ebook, Mastering Async/Await, is designed to give you an integrated understanding of async/await fundamentals and how async/await fits in the JavaScript ecosystem in a few hours. Get your copy!

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus