Important Bits

Catch This Javascript Error if You Can

April 08, 2018

A human I know came across code equivalent to this:

async function doSomething() {
    console.log('doing stuff before an async task...')
    await Promise.reject(new Error('I am the error'))
    console.log('doing stuff after an async task...')
}

and asked

Can we catch this rejected promise without changing doSomething()?

My first thought was NodeJS can help (sort of)

  • v1.4.1 gained a global event for unhandled promise exceptions that you can listen to with process.on('unhandledRejection', callbackFunction) and provide your own callback to handle the rejection.

  • v6.6.0 gained a feature where it emits a warning if a rejected promise isn’t handled

  • v7.0.0 made not handling promise rejections a deprecated feature. I found NodeJS v8.4.0 warns you with

    (node:4828) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

What about browsers? Chrome and Safari support unhandledrejection events a the moment, but nothing else does. If you don’t set up that handler and run doSomething() in a browser, you will find:

  • Chrome 65 emits an error to the console:

    Uncaught (in promise) Error: I am the error
        at doSomething (<anonymous>:2:26)
        at <anonymous>:1:1
    doSomething @ VM603:2
    (anonymous) @ VM611:1
    async function (async)
    doSomething @ VM603:1
    (anonymous) @ VM611:1
  • Firefox 59 emits the error, but doesn’t care that it is unhandled

    Error: I am the error     debugger eval   code:2:26
  • Safari 10.1.2 swallows the error silently.

I haven’t tried IE 11 and Edge yet. But global unhandled rejected promises listeners aren’t what we were looking for.

Let’s be specific

What happens if you try this?

try {
    doSomething()
    console.log('everything is ok, no errors at all')
} catch(error) {
    console.error('will it catch?', error)
}

The JS engine will log everything is ok, no errors at all. Why? I thought it was because the error was never thrown with the keyword throw, so there is nothing to catch with catch. There’s just a rejected promise hanging out in memory somewhere.

What about:

Promise.resolve(doSomething())
    .then(
        result => console.log('please do not run this callback'),
        error => console.warn('will it catch?', error)
    )
will it catch? Error: I am the error
    at doSomething (<anonymous>:3:26)
    at <anonymous>:1:17

That looks promising, but why does it work? doSomething() does not look like it is going to throw an error or reject a promise. Let’s try it with a failed fetch to see if i’m going crazy:

async function asyncRequest() {
    console.log('doing stuff before fetching...')
    await Promise.all([
        // this fetch will work
        fetch('https://jsonplaceholder.typicode.com/posts/1'),
        // intentional typo in URL so this fetch fails
        fetch('https://sonplaceholder.typicode.com/posts/2')
    ])
    console.log('doing stuff after fetching...')
}

Promise.resolve(asyncRequest())
    .then(
        result => console.log('ok js, please do not run this callback', result),
        error => console.warn('will it catch?', error)
    )
will it catch? TypeError: Failed to fetch
Promise.resolve.then.error @ VM534:14
Promise.then (async)
(anonymous) @ VM534:12

Why does this work? MDN says:

If the Promise is rejected, the await expression throws the rejected value.

But then why doesn’t this catch the error?

try {
    asyncRequest()
} catch(error) {
    console.warn(error)
}

Like we saw above, the global promise handler is invoked instead. What if:

asyncRequest().then(
        result => console.log('ok js, please do not run this callback', result),
        error => console.warn('will it catch?', error)
    )
will it catch? TypeError: Failed to fetch

That’s a surprise! I’d never think of writing this. My asyncRequest function does not explicity return a promise…

async functions always return promises

It turns out async functions always return promises. You can .then an empty async function!

async function noop() {}

noop().then(result => console.log('Implicit promise!', result))

What’s it going to say?

Implicit promise! undefined

You can .then an async function that returns a primitive!

async function math() { 
    return 1 + 1
}

math().then(result => console.log('Implicit promise!', result))
Implicit promise! 2

If it throws an error, the error is wrapped in a promise and marked as rejected.

async function throwAnErrorAboutBananas() {
   throw new Error('need more bananas')
}

throwAnErrorAboutBananas()
    .then(
        result => console.log('Implicit promise!', result),
        error => console.error('catch this error!', error)
    )

the error callback will be used:

catch this error! Error: need more bananas
    at throwAnErrorAboutBananas (<anonymous>:2:10)
    at <anonymous>:1:1

But the last 3 examples don’t use the await keyword. Soooo back to this example that surprised me at first:

async function asyncRequest() {
    console.log('doing stuff before fetching...')
    await Promise.all([
        // this fetch will work
        fetch('https://jsonplaceholder.typicode.com/posts/1'),
        // intentional typo so this fetch fails
        fetch('https://sonplaceholder.typicode.com/posts/2')
    ])
    console.log('doing stuff after fetching...')
}

asyncRequest().then(
        result => console.log('please do not run this callback', result),
        error => console.warn('will it catch?', error)
    )

Here’s what happens, working outward:

  1. fetch('https://sonplaceholder.typicode.com/posts/2') fails because the URL does not exist, and returns a rejected promise
  2. Promise.all sees one of the promises in the array is rejected, and returns a rejected promise
  3. await sees the reject promise and throws an error, aborting execution early
  4. asyncRequest is an async function, so it wraps the error in a promise - a rejected promise - and returns it
  5. asyncRequest returned a promise (implicitly, just like the spec says), and the .then call uses the error callback.

And why does this not catch the error?

async function asyncRequest() {
    console.log('doing stuff before fetching...')
    await Promise.all([
        // this fetch will work
        fetch('https://jsonplaceholder.typicode.com/posts/1'),
        // intentional typo so this fetch fails
        fetch('https://sonplaceholder.typicode.com/posts/2')
    ])
    console.log('doing stuff after fetching...')
}

try {
    asyncRequest()
} catch(error) {
    console.warn(error)
}
  1. fetch('https://sonplaceholder.typicode.com/posts/2') fails because the URL does not exist, and returns a rejected promise
  2. Promise.all sees one of the promises in the array is rejected, and returns a rejected promise
  3. await sees the rejected promise and throws an error, aborting execution early
  4. asyncRequest is an async function, so it wraps the error in a promise - a rejected promise - and returns it
  5. try doesn’t see the error get thrown - it just sees a rejected promise get returned, so it never triggers the catch
  6. If the js runtime environment notices unhandled rejected promises, it warns us or exits

And now I feel slightly better educated.


Adam Sullovey

Written by Adam Sullovey, powered by Gatsby.
Find me on codepen, github, or at Toronto meetups.