DEV Community

Cover image for JS illustrated: Promises
John Kapantzakis
John Kapantzakis

Posted on • Edited on

JS illustrated: Promises

This is the second JS illustrated article I've wrote. The first one was about the event loop

ES6 (ECMAScript 2015) has introduced a new feature called Promise. There are numerous excelent articles and books that explain the way that Promises work. In this article, we are going to try to provide a simple and understandable description of how Promises work, without digging into much detail.

Before we start explaining what a promise is and how it works, we need to take a look at the reason of its existence, in order to understand it correctly. In other words, we have to identify the problem that this new feature is trying to solve.

Callbacks

Promises are inextricably linked to asynchrony. Before Promises, developers were able to write asynchronous code using callbacks. A callback is a function that is provided as parameter to another function, in order to be called, at some point in the future, by the latter function.

Lets take a look at the following code

We are calling ajaxCall function passing a url path as first argument and a callback function as the second argument. The ajaxCall function is supposed to execute a request to the provided url and call the callback function when the response is ready. In the meanwhile, the program continues its execution (the ajaxCall does not block the execution). That's an asynchronous piece of code.

This works great! But there are some problems that might arise, like the following (Kyle Simpson, 2015, You don't know JS: Async & Performance, 42):

  • The callback function never gets called
  • The callback function gets called too early
  • The callback function gets called too late
  • The callback function gets called more than once

These problems might be more difficult to be solved if the calling function (ajaxCall) is an external tool that we are not able to fix or even debug.

It seems that a serious problem with callbacks is that they give the control of our program execution to the calling function, a state known as inversion of control (IoC).

The following illustration shows the program flow of a callback based asynchronous task. We assume that we call a third party async function passing a callback as one of its parameters. The red areas indicate that we do not have the control of our program flow in these areas. We do not have access to the third party utility, so the right part of the illustration is red. The red part in the left side of the illustration indicates that we do not have the control of our program until the third party utility calls the callback function we provided.

Alt Text

But wait, there's something else, except from the IoC issue, that makes difficult to write asynchronous code with callbacks. It is known as the callback hell and describes the state of multiple nested callbacks, as shown in the following snippet.

As we can see, multiple nested callbacks makes our code unreadable and difficult to debug.

So, in order to recap, the main problems that arise from the use of callbacks are:

  • Losing the control of our program execution (Inversion of Control)
  • Unreadable code, especially when using multiple nested callbacks

Promises

Now lets see what Promises are and how they can help us to overcome the problems of callbacks.

According to MDN

The Promise object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value.

and

A Promise is a proxy for a value not necessarily known when the promise is created

What's new here is that asynchronous methods can be called and return something immediately, in contrast to callbacks where you had to pass a callback function and hope that the async function will call it some time in the future.

But what's that it gets returned?

It is a promise that some time in the future you will get an actual value.

For now, you can continue your execution using this promise as a placeholder of the future value.

Lets take a look at the constructor

We create a Promise with the new Promise() statement, passing a function, called the executor. The executor gets called immediately at the time we create the promise, passing two functions as the first two arguments, the resolve and the reject functions respectively. The executor usually starts the asynchronous operation (the setTimeout() function in our example).

The resolve function is called when the asynchronous task has been successfully completed its work. We then say that the promise has been resolved. Optionally yet very often, we provide the result of the asynchronous task to the resolve function as the first argument.

In the same way, in case where the asynchronous task has failed to execute its assigned task, the reject function gets called passing the error message as the first argument and now we say that the promise has been rejected.

The next illustration presents the way that promises work. We see that, even if we use a third party utility, we still have the control of our program flow because we, immediately, get back a promise, a placeholder that we can use in place of the actual future value.

Alt Text

According to Promises/A+ specification

A promise must be in one of three states: pending, fulfilled, or rejected

When a promise is in pending state, it can either transition to the fullfilled (resolved) or the rejected state.

Alt Text

What's very important here is that, if a promise gets one of the fulfiled or rejected state, it cannot change its state and value. This is called immutable identity and protects us from unwanted changes in the state that would lead to undescoverable bugs in our code.

Getting control back

As we saw earlier, when we use callbacks we rely on another piece of code, often writen by a third party, in order to trigger our callback function and continue the execution of the program.

With promises we do not rely on anyone in order to continue our program execution. We have a promise in our hands that we will get an actual value at some point in the future. For now, we can use this promise as a placeholder of our actual value and continue our program execution just as we would do in synchronous programming.

Readable async code

Promises make our code more readable compared to callbacks (remember the callback hell?). Check out the following snippet:

We can chain multiple promises in a sequential manner and make our code look like synchronous code, avoiding nesting multiple callbacks one inside another.

Promise API

The Promise object exposes a set of static methods that can be called in order to execute specific tasks. We are going to briefly present each on of them with some simple illustrations whenever possible.

Promise.reject(reason)

Promise.reject() creates an immediately rejected promise and it is a shorthand of the following code:

The next snippet shows that Promise.reject() returns the same rejected promise with a traditionally constructed promise (new Promise()) that gets immediately rejected with the same reason.

Promise.resolve(value)

Promise.resolve() creates an immediately resolved promise with the given value. It is a shorthand of the following code:

Comparing a promise constructed with the new keyword and then, immediately resolved with value 1, to a promise constructed by Promise.resolve() with the same value, we see that both of them return identical results.

Thenables

According to Promises/A+ specification

thenable is an object or function that defines a then method

Lets see a thenable in action in the following snippet. We declare the thenable object that has a then method which immediately calls the second function with the "Rejected" value as argument. As we can see, we can call the then method of thenable object passing two functions the second of which get called with the "Rejected" value as the first argument, just like a promise.

But what if we want to use the catch method as we do with promises?

Oops! En error indicating that the thenable object does not have a catch method available occurs! That's normal because that's the case. We have declared a plain object with only one method, then, that happens to conform, in some degree, to the promises api behaviour.

In any case, it doesn't mean that an object which exposes a then method, is a promise object.

But how can Promise.resolve() help with this situation?

Promise.resolve() can accept a thenable as its argument and then return a promise object. Lets treat our thenable object as a promise object.

Promise.resolve() can be used as a tool of converting objects to promises.

Promise.all(iterable)

Promise.all() waits for all promises in the provided iterable to be resolved and, then, returns an array of the values from the resolved promises in the order they were specified in the iterable.

In the following example, we declare 3 promises, p1, p2 and p3 which they all get resolved after a specific amount of time. We intentionaly resolve p2 before p1 to demonstrate that the order of the resolved values that get returned, is the order that the promises were declared in the array passed to Promise.all(), and not the order that these promises were resolved.

In the upcoming illustrations, the green circles indicate that the specific promise has been resolved and the red circles, that the specific promise has been rejected.

Alt Text

But what happens if one or more promises get rejected? The promise returned by Promise.all() gets rejected with the value of the first promise that got rejected among the promises contained in the iterable.

Even if more than one promises get rejected, the final result is a rejected promise with the value of the first promise which was rejected, and not an array of rejection messages.

Alt Text

Promise.allSettled(iterable)

Promise.allSettled() behaves like Promise.all() in the sence that it waits far all promises to be fullfiled. The difference is in the outcome.

As you can see in the above snippet, the promise returned by the Promise.allSettled() gets resolved with an array of objects describing the status of the promises that were passed.

Promise.race(iterable)

Promise.race() waits for the first promise to be resolved or rejected and resolves, or rejects, respectively, the promise returned by Promise.race() with the value of that promise.

In the following example, p2 promise resolved before p1 got rejected.

Alt Text

If we change the delays, and set p1 to be rejected at 100ms, before p2 gets resolved, the final promise will be rejected with the respecive message, as shown in the following illustration.

Alt Text

Promise.prototype methods

We are now going to take a look at some methods exposed by the promise's prototype object. We have already mentioned some of them previously, and now, we are going to take a look at each one of them in more detail.

Promise.prototype.then()

We have already used then() many times in the previous examples. then() is used to handle the settled state of promises. It accepts a resolution handler function as its first parameter and a rejection handler function as its second parameter, and returns a promise.

The next two illustrations present the way that a then() call operates.

Alt Text

Alt Text

If the resolution handler of a then() call of a resolved promise is not a function, then no error is thrown, instead, the promise returned by then() carries the resolution value of the previous state.

In the following snippet, p1 is resolved with value 1. Calling then() with no arguments will return a new promise with p1 resolved state. Calling then() with an undefined resolution handler and a valid rejection handler will do the same. Finally, calling then() with a valid resolution handler will return the promise's value.

The same will happen in case that we pass an invalid rejection handler to a then() call of a rejected promise.

Lets see the following illustrations that present the flow of promises resolution or rejection using then(), assuming that p1 is a resolved promise with value 1 and p2 is a rejected promise with reason "Error".

Alt Text

We see that if we don't pass any arguments or if we pass non-function objects as parametes to then(), the returned promise keeps the state (resolved / rejected) and the value of the initial state without throwing any error.

But what happens if we pass a function that does not return anything? The following illustration shows that in such case, the returned promise gets resolved or rejected with the undefined value.

Alt Text

Promise.prototype.catch()

We call catch() when we want to handle rejected cases only. catch() accepts a rejection handler as a parameter and returns another promise so it can be chained. It is the same as calling then(), providing an undefined or null resolution handler as the first parameter. Lets see the following snippet.

In the next illustration we can see the way that catch() operates. Notice the second flow where we throw an error inside the resolution handler of the then() function and it never gets caught. That happens because this is an asynchronous operation and this error wouldn't have been caught even if we had executed this flow inside a try...catch block.

On the other hand, the last illustration shows the same case, with an additional catch() at the end of the flow, that, actually, catches the error.

Alt Text

Promise.prototype.finally()

finally() can be used when we do not care wheather the promise has been resolved or rejected, just if the promise has been settled. finally() accepts a function as its first parameter and returns another promise.

The promise which is returned by the finally() call is resolved with the resolution value of the initial promise.

Conclusion

Promises is a wide topic that cannot be fully covered by an article. I've tried to present some simple illustrations that will help the reader to get an idea of the way that promises work in Javascript.

If you find any errors or ommisions, please do not hestitate to mention them! I've put a lot of effort to write this article and I've learned many things about promises. I hope you liked it 😁

References

Top comments (2)

Collapse
 
markentingh profile image
Mark Entingh

callback hell is a myth. You don't need nested anonymous methods. You can create a method and pass the method name as the callback parameter.

Collapse
 
briancodes profile image
Brian

Great diagrams and explanations 👍