JavaScript Promises part 2: Going deeper with Promises

JavaScript Promises part 2: Going deeper with Promises

14 Jul 2019 error-handling promises javascript

Welcome to part 2 of this three-part guide to JavaScript promises. If you haven't already read part 1, I strongly recommend you start there - I'll wait for you here!

In this article we'll go deeper with promises. We'll see how they work with error handling, how (and why) we can work with promises that are resolved immediately, and how the Promise API allows for concurrent requests.

Error handling 🔗

So far we've hoped for the best and assumed our requests would succeed and therefore we could fulfil our promises. Obviously, that's not always a safe assumption, and so we need to budget for errors.

Fortunately, the Promise API has a really well-defined, structured means of handling errors. It all starts with promise rejection. When we reject a promise, we can act on this in one of two ways. The first is via the second param to then(), which accepts a callback to be executed if a promise is rejected, just as the first param accepts a callback to be executed if the promise fulfils.

let prom = new Promise((resolve, reject) => reject('Disaster!')); prom.then( val => console.log(val), reason => console.log(reason) //"Disaster!" );

Notice how we didn't do anything asynchronous in that example? Promises don't have to use asynchronicity, even if that is their main focus. We'll see a little later how they can be exploited to use either synchronous or asynchronous situations.

Notice how, just as we can pass along a resolution value when a promise fulfils, we can also pass along a rejection reason when the promise is rejected. In other words, fulfilled or rejected, we can pass along meaningful information about what happened to our callbacks.

We can achieve the same thing via catch().

let prom = new Promise((resolve, reject) => reject('Oh no!')); prom.catch(reason => console.log(reason)); //"Oh no!";

This is equivalent to:

prom.then(null, reason => console.log(reason));

...i.e. specifying then() but with a callback only for rejection, not fulfilment.

If a promise is rejected and there's no rejection handler to catch it, an uncaught exception will be thrown.

Remember early on we said one of the problems with the nested callbacks approach was we couldn't handle errors centrally? Rather, we had to manage error handling separately for each request? Not so with promises. We can chain several together, and have just a single error handler.

new Promise((res, rej) => res(1)) .then(value => { throw 'Oh no!'; }) .then(value => value+1) .catch(reason => alert(reason));

Here we first have a fulfilled promise, resulting from the call to new Promise(), then a rejected promise, from the implicit promise created by the first then() (recall that then() itself returns a promise).

Notice how, unlike before, we reject the promise by throwing an error, rather than, as before, manually calling a rejection callback. This has the same effect, i.e. results in the promise being rejected.

So our first then() essentially returns a rejected promise, with the reason 'Oh no'. And because it's a rejected promise, this means the callback passed to our second then() will not fire - because it's a fulfilment callback (first param), not a rejected callback (second param).

So our second then() doesn't have any effect. Instead, JavaScript immediately looks down the promise chain for an error handler (as noted above, if it doesn't find one, an uncaught exception error is thrown.)

And because JavaScript looks down the chain when a rejection occurs, this means our error handler would have fired regardless of which of the three promises were rejected. Contrast this with earlier, with the pyramid of doom, when we would have had to error-handle each request separately, in ever-deepening scopes.

It's even possible to chain further methods after catching an error.

new Promise((res, rej) => rej('Oh no!')) .catch(reason => 'Failed: '+reason) //remember catch() itself returns a promise .then(value => console.log(value)); //"Failed: oh no!"

There, our catch() catches the rejected promise, then implicitly returns its own promise, which is resolved with the compound string.

What if our catch() decides it can't handle the error and wants to delegate it to another handler? This is known as rethrowing, and that's possible too!

new Promise((res, rej) => rej({type: 'b', string: 'No!'})) .then(value => { /* this doesn't happen */ }) .catch(error => { if (error.type === 'a') { //handle error } else throw error; }) .then(value => { /* this also doesn't happen! */ }) .catch(error => { alert('Unknown error type...'); });

There, our initial promise is rejected. Because of this, the then() callback chained to it, which has only a fulfilment callback and no rejection callback, doesn't do anything. Instead JavaScript looks down the chain for an error handler, and finds it in our first catch().

However, that catch() can handle only errors of type "a". But it received an error of type "b". Time to continue the journey! Recall that catch() itself returns a promise, and because we throw an error from it that promise is rejected with our error object as its "reason". On we go to the next then(), which again does nothing, for the same reason as before.

Finally we get to the last catch(), which throws its hands up and gives it up as an unknown error.

Promise.resolve() & Promise.reject() 🔗

We met some promise methods in part 1, notably then() and catch(). Recall that all promise methods themselves return promises, which is what enables promise chaining.

Promise.resolve() and Promise.reject() are used to create and then instantly resolve a promise.

let foo = Promise.resolve('bar'); console.log(foo.toString()); //"[object Promise]" foo.then(value => console.log(value)); //"bar"

But hang on, what's the point of this? Aren't promises designed for asynchronous operations, and if so, why would we want to resolve one immediately?

Well, consider a function whose job is to retrieve and then cache data. We don't want to request it over AJAX every time, only the first time. Any further requests to the function should return the cached data. Let's mock that up.

let cachedData; function getData() { if (!cachedData) return new Promise(resolve => { let req = new XMLHttpRequest(); req.open('get', '/some/endpoint'); req.onload = response => { cachedData = response; resolve(response); }; req.send(); }); else return cachedData; }

The problem with this function is its return value is inconsistent. The first time, before the data is cached, it returns a promise that represents the request, which is resolved once we have the data. But subsequent times, it returns cached data. This makes it hard to work with.

Ideally we want our function to return a promise regardless of whether it's getting the data over AJAX or from the cache. Let's modify our else block slightly.

else return Promise.resolve(cachedData);

OK, now we're cooking. Our function always returns a promise - but in the case of cached data, it's resolved immediately, because there's nothing to wait for. That means we can now safely use the function with promises, and not have to worry about the type of return value it spits out.

getData().then(data => console.log(data)); //first time: AJAX getData().then(data => console.log(data)); //second time: cache - works same way!

Concurrent promises 🔗

In part 1 we saw how we could run consecutive AJAX requests, where completion order mattered, using promise chains. But wherever possible it's preferable to run requests concurrently, for speed. Can we do this with promises and know when they're all done?

Promise.all() 🔗

Meet Promise.all(), which accepts an array of sub-promises and reports when they're all complete. Like other promise methods, it itself returns a promise, so we can listen for completion the same way we'd listen to any other promise - by chaining a then()! Once again we'll use our request() function from part 1 which, recall, returns a promise.

let items = ['books', 'dvds', 'games'], proms = items.map(item => request('/get-products', {type: item})); Promise.all(proms).then(products => { console.log(products); //[0] = books, [1] = dvds, [2] = games });

Our then() is fed an array representing the fulfilment values (or rejection reasons) of each sub-promise, in the order we fed them to Promise.all(). That's why [0] is our books data, because the request to get books data was the first promise we created.

If we pass a non-promise to Promise.all(), it'll wrap it in one and instantly resolve it, like we saw with Promise.resolve(). This again means we can work with asynchronous and synchronous data together, like we saw before with our caching function.

Promise.all([42]).then(data => console.log(data[0])); //42

It's important to note that Promise.all() is fulfilled only if all the sub-promises fed to it are themselves fulfilled. If any are rejected, it too will be rejected.

let proms = [Promise.resolve(1), Promise.reject('Oops!')]; Promise.all(proms).then(values => { ... }); //error - uncaught exception

What if we expect some to be rejected, but still want to know when all are resolved - fulfilled OR rejected?

Promise.allSettled() 🔗

Promise.allSettled() is like Promise.all() but with two key differences. The first is that it doesn't care whether the sub-promises are fulfilled or rejected, merely that they are resolved (i.e. settled) one way or the other.

The second difference is that the data we get fed to our chained then() is different in structure. This time, because we don't know if each sub-promise got fulfilled or rejected, we get an an array of objects instead of an array of fulfilment values like before.

let proms = [Promise.resolve(1), Promise.reject('Oops!')]; Promise.allSettled(proms).then(values => { console.log(values); //[0] = object, [1] = object });

The object contains two keys:

  • status - "fulfilled or "rejected"
  • value OR reason - the fulfilment value or rejection reason

Promise.race() 🔗

What if we have several reqeusts but only care about the first to fulfil or reject? Admittedly this doesn't happen to often, but such is the richness of the Promise API that even this is possible.

Promise.race() returns a promise which is fulfilled or rejected with the value or reason of the first sub-promise to fulfil or be rejected.

let proms = [42, 43]; Promise.race(proms).then(value => console.log(value)); //42

In this contrived example, since both promises we fed to Promise.race() were synchronous, the first one wins out. Let's try an example where the winner isn't quite so certain.

function asyncValue(val) { return new Promise(resolve => { let wait = Math.round(Math.random() * 5) * 1000; setTimeout(() => resolve(val), wait); }); } let proms = [asyncValue('foo'), asyncValue('bar')]; Promise.race(proms).then(value => console.log(value)); //?

There, we create a function whose job is to mirror back the value we feed to it, but after a random number of seconds from 0-5. We don't know which will complete first, however, and promise created by Promise.race() is fulfilled with the winning value, "foo" or "bar".

Summary 🔗

Promises offer a well-defined, structured means of handling errors - in particular, a promise chain can be serviced by a single error handler for all promises in the chain.

It's possible to create and instantly resolve promises via Promise.resolve() and Promise.reject(), which can be useful for times when a function should return a promise but not necessarily do anything asynchronous.

Promise.all() allows us to wrap sub-promises in an outer promises as a means of controlling what happens when all of them resolve.

In part 3 we'll round things off by looking at promise events, and some of the other APIs that depend on promises - particularly async/await, which really brought promises into their own - as well as the Fetch API. Join me there!