The saga of async JavaScript: Promises
Roman Sarder
Posted on September 17, 2021
Intro
We have been learning async JavaScript patterns in a way that it should now make sense why Callbacks often might not be a sufficient solution to our day to day problems and how they helped Thunks to evolve into a powerful, lightweight tool. Although it didn't solve trust issues and Inversion of Control problem, the lessons we have learnt eventually resulted into a birth of a next pattern - Promises.
Explaining the approach
Armed with conceptual understanding and knowledge about innovations and drawbacks of Thunks we are now ready to take a look at what Promises can offer to us. We are not going to deep dive into Promise API and overwhelm ourselves with those fancy methods and properties right away. In the end of the day the particular method names and design solutions might differ between implementations, but the essential core idea will always stay the same. We are going to tackle the concept first and see how current JavaScript expresses it in terms of API.
Placeholder
What would be a good real world example of Promises? It appears to be a rather simple thing to explain. Let's imagine ourselves coming to a restaurant. Most of us like burgers of some kind, don't we? So you come and order one. What do you usually get in return? The receipt with order number. Eventually you are going to exchange your receipt for the burger when an order is ready but until then you can safely think and start reasoning about it as if it was already in your hands. The receipt became a placeholder for a future burger. Promises are much like that. For some value that will be fulfilled in the future, you are given a placeholder - a Promise - which later can be "exchanged" for a real value.
Inversion of Control: Round Three
It appears that both Thunks and Promises are following the similar philosophy - they provide you a something which you can work with until the real value shows up. But we had a problem of Inversion of Control with Thunks because they were using callbacks under the hood. We passed a function and hoped for the best. How could you "uninvert" the Inversion Of Control? What if we would be in control of executing the code which will run after the value is ready? Let's reсall a dumb example that we invented to illustrate how serious this problem can get:
fancyAsyncFunctionFromLibrary(function () {
chargeCreditCard()
})
Pseudocode to the rescue
We are not going to use current Promise API to help ourselves to solve this issue yet. Imagine that you don't have Promises invented at all. Flex you brain cells and try to think of a way to resolve the Inversion Of Control problem in this code using pseudocode. How would we modify an example above in order to get in control of executing our callbacks. Thankfully, there are plenty of patterns in programming that can inspire you. What about Event Emitters?
const futureValuePlaceholder = fancyAsyncFunctionFromLibrary()
futureValuePlaceholder.on('complete', chargeCreditCard);
We made ourselves a fancyAsyncFunctionFromLibrary
which now returns an event emitter. Given the knowledge of what events you can get, we can attach our callbacks however we want. In this example, we run our callback once something is completed in that function so we could charge a credit card. We could subscribe to an error event in the same fashion. Or we could decide to not do so. We could even imagine ourselves detaching our listener once a complete event fired. There are plenty of things that we can do using this model. The pseudocode we've written basically says: "Give me an object which fires different events, and I will decide what events I will subscribe to and how I will run my functions in response to them". And the interesting part, it is not looking that different than Promises we use everyday. Instead of on
method we have then
, which actually knows what event it should subscribe your callback to. Despite the fact that callbacks are still the essential part of our code, we were able to regain the control of the execution and run our functions on our terms using a nice and clean API. To summarise, the other way you can think of Promises is that they are much like Event Emitters. But to solve the Inversion of Control disaster, we need something more than an API. There is a missing part.
Trust enforcing
We still might be in doubt of how our callbacks will be run. There is a list with a decent amount of concerns about callbacks that is menacingly standing right next to our newborn event emitter. We desperately need trust to be introduced to eliminate those. The Promises would not be of much use if they didn't incorporate trust enforcing mechanisms. Thankfully, when you are using Promises in current JavaScript, JavaScript itself ensures that:
- promises are immutable
- errors are not swallowed
- the promise will either succeed or throw an error
- it only resolves once
- no actions at a distance
Pretty neat, huh? Having a well defined and strict behavior, we are no longer questioning ourselves about the way our callbacks are run. The immutable part is also very important. JavaScript makes sure that when you pass your Promise to a third party code, there is no way that it will somehow get mutated or changed in any way. You simply cannot affect both promise's state and a value inside. No action at a distance. Also our code is now safe from being called multiple times and we are always getting an error no matter what. Even if you are not handling that error explicitly in your Promise, it will bubble up as Unhandled Promise rejection
and you will not miss compiler yelling at you.
Show us Promises, sir
Let's take our pseudocode that we wrote before and use Promises this time:
fancyAsyncFunctionFromLibraryWithPromise () {
return new Promise((resolve, reject) => {
fancyAsyncFunctionFromLibrary(resolve)
})
}
fancyAsyncFunctionFromLibraryWithPromise()
.then(chargeCreditCard)
.catch(handleError)
Our fancyAsyncFunctionFromLibrary
now returns a Promise that we have created ourselves. You are getting a first-class object which you can pass around much like any other value. When constructing a Promise, you pass it a callback which expects two arguments: a resolve
and reject
functions. These are your tools to switch the state of promise to either a fullfilled state or rejected. We call a then
method to attach a callback which will be executed once Promise is fullfilled, in other words resolve function got called inside of our Promise. That callback receives a value of Promise if there is any. On the opposite side there is a catch method for error handling which works in a similar way. We have to handle only two possible cases and we have two corresponding methods that we need. The code itself reads much like human language: "Do something that takes time, then pass it to this function, but if something went wrong, catch the error and pass it to this function".
Flow control
Let's try ourselves in writing some sequence of operations using promises and see how they look like in a bit more common example:
readFileOnePromise
.then(fileContents => {
console.log('first file', fileContents)
return readFileTwoPromise
})
.then(fileContents => {
console.log('second file', fileContents)
return readFileThreePromise
})
.then(fileContents => {
console.log('third file', fileContents)
})
This time temporal dependencies between operations don't have to result into more nesting and they all stay on the same level throughout the program. The notable feature which makes working with Promises much easier is chaining.
Chaining
Chaining is some sort of syntax that allows you to do multiple object method calls without intermediate variables. This is achieved by each method returning the object. Inside then
method's callback you can either return a Promise or a value. In case you returned a Promise, the next then
will not fire its callback until this Promise is resolved. You can handle both in the same way and this results in a time independent value wrapper much like Thunks. But oftentimes it is only API which makes people use Promises and think that they are a silver bullet in a world of async programming. Remember that the important part about Promises is not their API, but their idea and concept which at some time in the past innovated the way you work with asynchronous code in your programs. It is about their ability to finally solve Inversion of Control issue while keeping the advantages of being a container around the data which you can pass around and a placeholder for a future value.
Callbacks.. again?
Yes, we still have callbacks. Actually, if you look at Promises carefully, you would see that they might look like callback managers! And that's the third and final way I was able to think of Promises. They use callbacks for the same well known tasks - running code once something is completed, and in addition they bring in the trust that we needed. The important point in Promises is that they reduce the gap between async and sync code even further. There are two very important things about synchronous functions:
- they return value
- they throw errors
Promises composition
More importantly, if we are talking about function composition, if any of functions in a composition throws an error, that error bypasses all other composition layers and goes all the way up so the client code would be able to catch it. In case of Callbacks, returning value was impossible since they just were not ready at a moment of call. Similarly, you could not throw errors because there was nobody to catch them and with callbacks you would need to manually propagate those errors. Promises do an important job of bringing back those things into asynchronous world by saying that each function should return a promise and guaranteeing that an error will bubble up. If written correctly, those then/catch blocks compose in a similar manner as their synchronous counterparts by having fulfillments creating a compositional chain with rejections being able to interrupt it at any stage that is only handled by someone who declares that he is ready to handle it.
A bit of functional programming
then
method instead of being viewed as "callback attaching mechanism" could be viewed as "trasformation application". It basically allows us to apply transformation on value inside a promise and create a new one which will be passed down the chain. From this point of view, Promises are very similar to Monads with their ability to chain and apply functions on underlying values. Although the current JavaScript API for Promsies itself is not as 100% pure as functional programmers would desire, the monadic nature of promises is quite obvious.
More of fancy API
Promises come with plenty of additional methods to improve your flow control out of the box. Promise.all
will take an array of promises and return a new promise which resolves once all promises are resolved. Promise.any
is similar in a way that it expects an array of promises, but will return a promise which resolves once at least one promise is resolved. If there are no resolved promises, the result promise gets rejected. I will not go through each and every method on Promise object in JavaScript but you probably get the idea. Promises also provide you some useful abstractions which help you to orchestrate not one, but a group of promises in a more complex scenarios. Once you start discovering the documentation, you will find yourself inventing those abstractions on the fly. Not all of them are currently implemented, but nobody stops you from using third party promise libraries. You can even create one yourself!
Downsides
I noticed that there are some articles about Promises that focus on API misusage when talking about the downsides. There are also many of them which don't talk about any problems with Promises at all. There are a couple of things left that Promises didn't manage to solve or provide. My attitude towards most of the issues with Promises could be described as "Ah, but this and that thing would also be handy, altough it would not make sense in this pattern". Having our main enemy - Inversion of Control - defeated, we are now only looking for more features to make our toolset complete. And you will see that things described below are screaming for another pattern to be created to use alongside Promises. So take these points as "nice to haves" instead of "need to fix".
Still out of main flow
This could be a debatable point. While Promises reduce the number of nested callbacks you are working with, they are not removing them entirely. Using standard Promises, there is no way for our synchronous code to "wait" for promise. Consider this example:
const func = (value) => {
let promise = somePromiseBasedFunction();
let promiseValue = ?;
promise.then(function(result){
// I can access the value here, but there's
// no way for me to get it up in the main
// scope and have `func` return its value
});
const finalValue = someOtherFunction(promiseValue);
return finalValue;
}
Altough the purpose of promises is to not block your program, oftentimes we really need this kind of mechanism to be available. This would close the gap between sync and async code even more. Techically, this got solved in later versions of JavaScript with async/await, but those are based on generators and are subject to a separate article.
Not cancellable
This one also contradicts the ideology behind promises. No doubt, an ability to cancel a promise with an outgoing AJAX request would be super great, but that would also mean that Promises are no longer immutable and suddenly we are now vulnerable to an "action at distance" problem.
Missing abstractions
Just a "nice to have" thing which often makes you create those methods from scratch or use third party library as an alternative. A list of available Promise abstractions implemented currently can feel a bit limiting in some cases. For example, imagine yourself chaining 10 then
calls and trying to remember that each time you need to return a Promise to make a composition work. It can easily become annoying and prone to errors when dealing with a long chain. How about sequence
method which would accept a variable number of functions and do that for you? It will automatically chain those function calls and ensure that each of them will return whatever the next one needs to make it work. As I said, one could come up with at least a couple of useful methods that are not presented in current API and it would be great to have them implemented in a language itself.
Outro
It has been a great journey. We finally got rid of Inversion of Control problem and by accumulating our knowledge and experience across the patterns we've managed to deep dive into Promises and properly understand why they became a thing. At this point, the creation of Promises should be a pretty obvious thing for you because this pattern is mostly a correction of mistakes from previous ones. They are currently an important and powerful tool in our arsenal and they will stay like this for a while. But the picture of ideal async programmer's life is incomplete and there are missing features and concepts which are to be implemented. Similar to callbacks, Promises themselves will serve as a foundation for a next pattern which will enhance their capabilities to provide us even better experience. As we go further, topics will keep getting more and more challenging, so I am very excited to tell you about other patterns. In next article we will talk about Async Generators and see how async/await feature works under the hood.
Posted on September 17, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.