April 8, 2018

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)

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:

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.