Execute Program

JavaScript Concurrency: Error Handling in Async Functions

Welcome to the Error Handling in Async Functions lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • We've seen that throwing an exception inside of a then callback causes the promise to reject. The thrown exception is transformed into a rejected promise.

  • >
    Promise.resolve()
    .then(() => {
    throw new Error('it failed');
    });
    Asynchronous Icon Async Result:
    {rejected: 'Error: it failed'}Pass Icon
  • What about the opposite: transforming a rejected promise into a thrown exception? That's exactly what happens when we await a rejected promise. The rejection reason is thrown as a normal JavaScript exception, allowing us to catch it.

  • >
    async function willFail() {
    try {
    await Promise.reject(new Error('it failed'));
    } catch {
    return 'we caught an exception';
    }
    return 'we got to the end';
    }
    willFail();
    Asynchronous Icon Async Result:
    {fulfilled: 'we caught an exception'}Pass Icon
  • It's important to note that there's nothing special about this try/catch. It doesn't know anything about promises. Instead, the await expression throws a regular JavaScript exception, which we then catch in the regular way.

  • What if there's an exception inside of an async function, but we don't catch it? When an exception in an async function is uncaught, it's automatically translated into a rejected promise. This is part of a general rule: async functions always return promises, no matter what.

  • >
    async function willThrow() {
    throw new Error('it failed');
    }
    willThrow();
    Asynchronous Icon Async Result:
    {rejected: 'Error: it failed'}Pass Icon
  • To summarize: await and async both transform between exceptions and rejected promises. An async function turns any exception into a rejection. And awaiting a rejected promise throws an exception.

  • Here's an example that shows both of those facts together. The async function here awaits a rejected promise. It returns a rejected promise where the reason is 'Error: it failed'.

  • >
    async function willFail() {
    await Promise.reject(new Error('it failed'));
    }
    willFail();
    Asynchronous Icon Async Result:
    {rejected: 'Error: it failed'}Pass Icon
  • It's tempting to look at that example and think, "We awaited a rejected promise, so the async function returned that rejected promise." But here's what actually happened:

    • We awaited a rejected promise.
    • Awaiting a rejected promise always throws an exception.
    • That exception made the function exit early, as it does in any function, whether it's async or not.
    • Because it was an async function, the exception was translated into a rejection.
  • Here's a code problem:

    Modify this function to catch the exception caused by awaiting a rejected promise. Return null when the await throws an exception.

    async function getOwnerForCat(cat) {
    if (cat === undefined) {
    throw new Error('cat does not exist');
    } else {
    /* Find the cat's owner; code omitted. */
    }
    }

    async function run() {
    try {
    return await getOwnerForCat(undefined);
    } catch {
    return null;
    }
    }

    run();
    Asynchronous Icon Async Result:
    Goal:
    {fulfilled: null}
    Yours:
    {fulfilled: null}Pass Icon
  • The interaction between async/await and exceptions may seem complex, and it is complex! However, that complexity results from a deep tension between promises and async/await.

  • Promises represent asynchronous operations as values. This is nice because we can pass them to functions, return them from functions, store them in variables, etc. But async/await treats asynchronous operations like regular control flow within a JavaScript function. This is nice because we can smoothly mix async/await code with if, for, while, try, and catch.

  • Both ways have their own strengths, so we want to be able to mix them as needed. But if we allow both ways to be used, then we need rules for translating between the two, as we've seen in this lesson. The good news is that, as with everything in programming, switching between promises and async/await becomes natural with some practice.

  • We can now summarize the rules of exceptions in async/await functions:

    • async functions always return promises.
    • await turns rejected promises into exceptions.
    • Exceptions inside async functions turn into rejected promises.
  • One final point about exceptions in async functions: there's an important difference between return somePromise and return await somePromise. The await translates rejected promises into exceptions. If we directly return the promise, rejections aren't translated into exceptions at all.

  • Here's a pair of examples showing the difference. The first example does return await aRejectedPromise. It throws, so our catch works.

  • >
    async function fail() {
    try {
    return await Promise.reject(new Error('oh no'));
    } catch (e) {
    return 'caught the error';
    }
    }
    fail();
    Asynchronous Icon Async Result:
    {fulfilled: 'caught the error'}Pass Icon
  • The second example returns the promise directly: return aRejectedPromise. There's no await, so the rejection doesn't turn into an exception, so there's nothing for our catch to catch. Instead, this example returns the original rejected promise.

  • >
    async function fail() {
    try {
    return Promise.reject(new Error('oh no'));
    } catch (e) {
    return 'caught the error';
    }
    }
    fail();
    Asynchronous Icon Async Result:
    {rejected: 'Error: oh no'}Pass Icon
  • Fortunately, linters can help with this. For example, TypeScript users can use typescript-eslint's return-await rule to make sure that they always return await. Plain old discipline can also help: if you always await promises inside of async functions, this problem won't occur.