Why is it possible to try-catch an async-await call?

Viewed 4134

There is a common anti pattern in JavaScript:

function handleDataClb(err, data) {
    if(!data) throw new Error('no data found');
    // handle data... 
} 

function f() {
    try {
        fs.readFile('data', 'utf8', handleDataClb);
    } catch(e) {
        // handle error... 
    }
}

This try-catch in f will not catch errors in the handleDataClb as the callback is called in a later stage and context where the try-catch is not visible anymore.

Now in JavaScript async-await is implemented using generators, promises, and coroutines, as in:

// coroutine example 
co(function* doTask() {
    try {
        const res1 = yield asyncTask1(); // returns promise
        const res2 = yield asyncTask2(); // returns promise
        return res1 + res2;
    } catch(e) {
        // handle error... 
    }
});

// async-await example
async function doTask() {
    try {
        const res1 = await asyncTask1(); // returns promise
        const res2 = await asyncTask2(); // returns promise
        return res1 + res2;
    } catch(e) {
        // handle error... 
    }
}

This way the try-catch works, which is often mentioned as one great advantage of async-await over callbacks.

Why and how does the catch work? How does the coroutine aka async manage to throw the error inside the try-catch when one of the asyncTask calls results in a promise rejection?

EDIT: as others have pointed out, the way how the JavaScript engine implements the await operator can be very different from the pure JavaScript implementation used by transpilers like Babel and shown above as coroutine example. Therefore to be more specific: how is this working using native JavaScript?

2 Answers

Why and how does the catch work? How does the coroutine aka async manage to throw the error inside the try-catch?

A yield or await expression can have 3 different outcomes:

  • It can evaluate like a plain expression, to the result value of that
  • It can evaluate like a throw statement, causing an exception
  • It can evaluate like a return statement, causing only finally statements to be evaluated before ending the function

On a suspended generator, this can be achieved by calling either the .next(), .throw() or .return() methods. (Of course there's also a 4th possible outcome, to never get resumed).

…when one of the asyncTask calls results in a promise rejection?

The awaited value will be Promise.resolve()d to a promise, then the .then() method gets invoked on it with two callbacks: when the promise fulfills, the coroutine is resumed with a normal value (the promise result), and when the promise rejects, the coroutine is resumed with an abrupt completion (exception - the rejection reason).

You can look at the co library code or the transpiler output - it literally calls gen.throw from the promise rejection callback.

async funtions

An async function returns a promise that is resolved by a value returned by the function body, or rejected by an error thrown in the body.

The await operator returns the value of a fulfilled promise or throws an error, using the rejection reason, if the awaited promise is rejected.

Errors thrown by await can be caught by try-catch blocks inside the async function instead of allowing them to propagate up the execution stack and rejecting the promise returned by calling the async function.

The await operator also stores the execution context before returning to the event loop, in order to allow promise operations to proceed. When internally notified of settlement of the awaited promise, it restores the execution context before proceeding.

A try/catch block set up in the async function's execution context is not altered or rendered ineffective simply because the context has been saved and restored by await.

As an aside

"async-await is implemented using generators, promises, and coroutines"

may be part of how Babel transpiles async function and await operator usage, but native implementations could be implemented more directly.


Generator functions (Update)

A generator function's execution context is stored in its associated generator object's internal [[Generator Context]] slot. (ECMA 2015 25.3.2)

Yield expressions remove the generator's execution context from the top of the execution context stack (25.3.3.5 of ES6/ECMAScript 2015)

Resuming a generator function restores the the function's execution context from the generator object's [[Generator Context]] slot.

Hence generator functions effectively restore the previous execution context when a yield expression returns.

Throwing an error within a generator function for normal reasons (syntax error, a throw statement, calling a function which throws) can be caught by a try-catch block as expected.

Throwing an error by means of Generator.prototype.throw() throws an error within the generator function, originating from the yield expression that last passed control from the generator function. This error can be trapped by try-catch as for ordinary errors. (Refs MDN using throw(), ECMA 2015 25.3.3.4

Summary

Try-catch blocks around yield statments used in await transpilation code work for the same reason they do around await operators within native async functions - they are defined in the same execution context as the error is thrown for a rejected promise.

Related