Thoughts on Whatnot
A blog about .
Awaiting Return

Introduction

Recently, I’ve found myself doing a lot of work with Node.js for various different reasons. As it’s primarily UI work, one of the features I’ve had to get used to was Promises, followed quickly by async/await. I’ve never been a fan of this model, but the Node approach to it seems far cleaner than earlier attempts that I had used, such as C#’s. Overall, I like the approach, although its complete lack of support by Node’s standard library, and especially by EventEmitter, is extremely awkward.

While the approach works overall, I quickly ran into a potentially insidious bug that is quite easy to introduce. Consider the following code:

const example = async () => {
  return new Promise((resolve, reject) => {
    resolve(3)
  })
}

console.log(await example())

As you might expect, the code prints 3 to the console. It’s important to understand exactly what’s happening here, though. The call to example() produces a Promise that resolves to another Promise. This is because an async function wraps its return in a Promise that resolves to the return value, which in this case is the newly created Promise. If a Promise resolves to another Promise, the outer Promise will resolve to whatever the inner one resolves to in a recursive manner. This behavior extends to uses of await, meaning that await example() resolves the Promise created by the async keyword, and that Promise resolves to the resolved value of the inner Promise, which in this case is 3.

Another side effect of the await keyword is that it converts Promise rejections into thrown errors. For example:

const example = () => {
  return Promise.reject(3)
}

try {
  console.log(await example())
}
catch (err) {
  console.error(`Error: ${err}`)
}

In this case, the code prints Error: 3 to the console. Similarly, an async function converts a thrown error into a rejection in the returned Promise.

The Problem

However, what if you combine these pieces of information? For example, what does the following code do?

const complicatedThing = async () => {
  let succeeded

  // Some complicated thing that could cause an error.

  if (!succeeded) {
    throw new Error('failed')
  }
  return 'succeeded'
}

const example = async () => {
  try {
    return complicatedThing()
  }
  catch (err) {
    console.warn(`Error: ${err}`)
    return 'some default'
  }
}

console.log(await example())

If you said ‘It crashes due to an uncaught error.’, you’d be correct. If you didn’t, you may be wondering how that could be. Clearly the call to complicatedThing() is wrapped in a try/catch, isn’t it? Well, yes, technically it is. But there’s an issue.

complicatedThing() returns a Promise that is automatically created by its being an async function. When throw new Error('failed') is inevitably called due to succeeded being undefined, that Promise rejects. But where is that rejection handled? It’s handled by the await keyword prefixing the call to example(). That await resolves the promise returned by example(), and that Promise resolves to another Promise, so it resolves that too, and that Promise is then the one that rejects, resulting in an uncaught error.

This problem can relatively easily be fixed by just prefixing the call to complicatedThing() with another await. This will result in the complicatedThing() Promise rejecting inside the try block, thus allowing it to be caught. While the fix is fairly easy, I highly recommend getting in the habit of prefixing any async function called in a return with an await, provided of course that that function being returned from is itself async.

And that’s about it. While Node’s approach to async/await may be better than some others, it can also lead to some awkward little things like this. Not a horrible problem perhaps, but still something to watch out for.

comments powered by Disqus