14 February 2018

JavaScript Promises and Errors

It’s easy to get confused about how errors and catches bubble up through JavaScript Promises, and you might find yourself asking what happens when an error is thrown inside a Promise? I think there’s a good mix to be had of learning from reading and learning from doing. I think both can reinforce each other. In this post, I have focused on “doing” and compiled a bunch of little tests to point out how code executes in different scenarios.

Despite what people say:

a promise is something you can’t break

You can definitely make some mistakes.

Here are a bunch of examples of what happens in different scenarios to help get a sense for what happens when.

1. Normal errors

// a synchronous error thrown outside the promise, raises an exception
// that must be caught with try/catch

function example() {
  throw new Error("test error outside");
  return new Promise((resolve, reject) => {
    resolve(true);
  });
}

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.error(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// try/catch(Error: test error outside)

2. Errors inside Promises

// an error thrown inside the promise, triggers .catch()

function example() {
  return new Promise((resolve, reject) => {
    throw new Error("test error inside");
    resolve(true);
  });
}

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.error(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .catch(Error: test error inside)

3. Calling reject(…)

// explicitly calling reject, triggers .catch()

function example() {
  return new Promise((resolve, reject) => {
    reject("test reject");
  });
}

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.error(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .catch(test reject)

4. Not specifying a .catch(…)

// failing to catch a reject means the code will continue to execute
// as if everything was fine, except it prints a warning
//
// in the future it will be a runtime error that terminates the process

function example() {
  return new Promise((resolve, reject) => {
    reject("test reject");
  });
}

try {
  example().then(r => console.log(`.then(${r})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// (node:25692) UnhandledPromiseRejectionWarning: test reject
// (node:25692) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2)
    // (node:25692) [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.

5. Not specifying a .catch(…) when Promise resolves ok

// the UnhandledPromiseRejectionWarning only triggers when an
// unhandled promise actually occurs. In the example below it
// appears fine, but future hidden errors may be lurking
//

function example() {
  return new Promise((resolve, reject) => {
    resolve("test resolve");
  });
}

try {
  example().then(r => console.log(`.then(${r})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .then(test resolve)

Let’s now look at some examples with the es7 async await syntax. Any function that is defined as async automatically returns a promise. If you return a promise object in an async function, then it will return that promise, if you return any other object, then it will return a promise that resolves to that value. Separately, if an error is thrown, then the return value is a rejected promise for which .catch(…) is triggered with the error it experiences.

You can use await inside an async function to automatically resolve a line as a promise (note: not needed in the final return call). This will evaluate to the value inside the .then(…) of that promise or throw an error with the value inside the .catch(…) of that promise if it is rejected. Likewise, any errors bubble up in the same way that a reject call would, which allows for the same handling of synchronous errors and promise rejections.

6. Async functions and synchronous errors

// all async functions return promises and any errors that
// are thrown automatically trigger their catch method
//

async function example() {
  throw new Error("test error at top of example");
}

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.log(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .catch(Error: test error at top of example)

7. Async functions and synchronous errors inside an await

// using await expects and parses the returned promise.
// If the function throws an error outside the promise,
// this gets thrown inside the async function and that
// bubbles up to the catch of its own promise.
//

async function example() {
  // NOTE: this should more simply be `return inner()`, but I wanted to show `await`
  const val = await inner();
  return val;
}

const inner = () => {
  throw new Error("test error outside promise");
  return new Promise((resolve, reject) => {
    resolve(true);
  });
};

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.log(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .catch(Error: test error outside promise)

8. Async functions and errors inside an await’s Promise

// If the promise returned in an await triggers a
// catch, then that also gets thrown as an error inside
// the async function and once again it bubbles up to the
// catch of its own promise.
//

async function example() {
  const val = await inner();
  return val;
}

const inner = () => {
  return new Promise((resolve, reject) => {
    throw new Error("test error inside promise");
    resolve(true);
  });
};

try {
  example()
    .then(r => console.log(`.then(${r})`))
    .catch(e => console.log(`.catch(${e})`));
} catch (e) {
  console.error(`try/catch(${e})`);
}

// > Output:
//
// .catch(Error: test error inside promise)

9. Async functions and calling reject(…) inside an await’s Promise

Left as a practice for the reader! (Spoiler alert: it’s the same result as the prior example.)

I promise, things will get better

Some takeaways

  1. Always add a .catch(…) whenever you have one or more .then(…) statements—you don’t want an unhandled Promise rejection! A promise rejection will pass through a series of promises to the first catch it reaches, so I usually try to use the fewest catches possible and leave it up to the top-most level code to do the catch. For example, a function that returns a promise usually shouldn’t have a catch inside it, but your UI code or main function in a script should have a catch to handle any errors in any async/promise calls.
  2. If you expect a function to synchronously throw an error before a Promise, then you’ll want to wrap it in a try-catch, however, you likely don’t want functions to work like this and more likely such an error will be a coding bug that should be raised. (Excercise for the reader, explore the approach of starting your function with Promise.resolve(() => { … }) and putting the synchronous code in there.)
  3. Inside async functions, synchronous errors and Promise rejections all bubble up as a Promise rejection in the function’s returned Promise object—this is awesome and avoids the workarounds in takeaway #2.

Further exploration

  1. Try chaining multiple .then(…) calls in a row. This avoids indenting ever deeper as you chain more calls. Both .then(…) and .catch(…) return a Promise, whether you return Promise or a regular variable from the from either. See how a Promise rejection will short-circuit a chain of then calls to the next catch. As previously mentioned, this can simplify your handling of Promise rejections to just one .catch(…) with many Promises.

  2. Chain .then(…) calls after a .catch(…) and trigger different paths of execution.

  3. See what happens when you throw an error inside a .catch(…). You’ll get another unhandled Promise rejection!

  4. Play around with how returning a value within a .then(…) keeps the Promise going and allows you to chain without embedding deeper and deeper.

  5. Read some more about promises, e.g., “You’re missing the point of Promises”

  6. Check out these slides on Callbacks, Promises, and Async/Await that I presented in a talk at the Recurse Center.

Thank you for making it to the end! LMK if anything weird happens with the gifs, I just added the paused state and the play buttons using HTML5 canvas.

promise thumb touch




Did you find this helpful or fun? paypal.me/mrcoles
comments powered by Disqus

Peter Coles

Peter Coles

is a software engineer living in NYC who is building Superset 💪 and also created GoFullPage 📸
more »

github · soundcloud · @lethys · rss