icon sc-linkedinlogo of codepen-iconlogo of github-iconyoutube play button

notes by Adam Sullovey

web & mobile application developer
practicing in Toronto, ON

Catch This Javascript Error if You Can

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:

sync 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 rejected promise and throws an error, aborting execution early and returning an error
  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?

sync 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 and returning an error
  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. The js runtime environment notices a rejected promise was never handled and warns us

And now I feel slightly better educated.