(A Beginner’s Guide)
I think that most good stories start with a truism, so let’s do that. The best programmers are lazy. They like being lazy so much that they will stay up all night inventing a new, more efficient way to be lazy, and get into passionate arguments with twenty thousand of their best friends about what form of laziness is in fact the laziest (and therefore, the best).
More seriously, figuring out ways to just do less is a key part of making applications that are readable, maintainable and efficient. You’ll find that in coding, very little that we do is done “for its own sake” — every tool that we use (hopefully) makes doing something that we need to do easier.
Promises are no different. It can be tricky to wrap your head around exactly what they are and how to use them when you are thinking about them in the abstract. Understanding them conceptually first is, to my mind, a chump’s game. Very organically, there will come a moment as you build out some application when you will look at your code and say, “Oh, crap. What I really need to do here is make sure that my program doesn’t try to use something I’ve sent it to get until it’s actually gone and gotten it.” And I’ll happily bet you all an internet dollar that that’s the moment when promises will suddenly click for you.
Let’s see if I can help you get there though, anyhow. In order to talk about what promises do, we need a little bit of information about why we need them. Let’s talk about what happens when you tell node, or V8, to run a file. This will help us understand what the problems of managing asynchronous actions are, how they arise, and why promises are such an awesome, usable solution to those problems. In doing this, we’re probably going to tread some pretty familiar ground. The goal of this article, however, is not just to give you some informal documentation of promises, but to fit them within the narrative of how we build our applications. So bear with me if the beginning feels a little…basic.
Part I: The Land Before Promises
How does our code normally execute? Each expression runs, and then moves on to the next one. It’s unidirectional, right? This is great, because it’s predictable. When we invoke a function on line fifty, we expect it to have access to any information that’s been established on line forty. No surprises. And if all we’re doing is running code that is contained in one space and executes at a normal speed, that’s exactly how it will work.
But sometimes, we have to do things that are so big or complicated that even our super fast computers do them slowly — by computing standards — stuff that might take hundreds of milliseconds. Like talking to a database, or reading a file.
Fortunately, it’s not quite that simple. Our call stack isn’t the only thing that manages our code. Along with it, we have a series of APIs (Web or C++, depending on whether we’re in the front or back end), that help us handle asynchronous actions.
So, if we do this:
That fs.ReadFile will run, and head right off of our callstack, freeing us to run the console.log statement on line twenty. Great, right? Except we have one, pretty serious problem: that console.log is not going to have our veryImportantFile in it — line twenty is going to execute long before fs.ReadFile ever gets back with data to fill up ourStuff. And our code still executes unidirectionally: we can’t just DC Al Coda back up to the variable declaration whenever it just happens to be convenient for us. So what do we do?
One thing we can do is to give that asynchronous function (the fs.ReadFile) a callback function, and put anything we need done with the data there. When an async function takes a callback, that argument will run upon the completion of the asynchronous action, right? Not quite.
We don’t know how long any given async action will take to complete. Could be a tenth of a second, could be two seconds, who knows? If we could somehow trigger an async’s callback to run whenever it happened to finish, it could theoretically just execute in the middle of some other function, which would be weird and probably break things. It would completely rob us of that predictability we’re relying on. Fortunately, that’s not how it works.
When an async action is done doing its thing, the callback function gets pushed on to the task queue, but it doesn’t run there. The only place our callback will run is in the call stack, just like any other function. And how it gets from the queue to the stack is handled by the event loop.
The event loop has just one job: it is constantly watching the call stack. When the call stack is empty, the event loop checks the task queue and grabs the first operation waiting in the queue to put on the stack. What this means in practice is that our callback function will run at the first point after the asynchronous action completes that the call stack becomes clear. Which isn’t totally predictable, but sounds a lot better than where we were a minute ago, right?
So, at this point, our code will look something like this:
That’s not too terrible to look at, is it? It’s not really any worse at this point than using any old array method — array.map() isn’t something that we think of as really kludging up our code. But what if, instead of just logging that information, we actually need to do something more substantial with it? What if, say, we’re reliant on some data in veryImportantFile.txt in order to direct us to the next file we have to read? That would be another asynchronous action, wouldn’t it? And that would have it’s own callback, that would run once both things had completed and the call stack had cleared at least twice. And what if that file was something that we actually needed to do something asynchronous with…
Oh, and before I forget: did I mention that we’re actually skipping a step here. What if, when our program went to get our veryImportantFile, something went wrong? Maybe there was a typo in the name, maybe the file is corrupted, maybe there’s just some other problem that we couldn’t anticipate. What then? For our callback function to have any purpose, it presumably relies on whatever the results we’ve been waiting for are, so it wouldn’t make sense to run the same function if what we get back is a bunch of error messages.
Vanilla asynchronous callbacks actually take two functions as arguments — one for handling the success case, and one for the failures. And they’re actually “error first” (as in, the first function parameter will be treated as the error case callback) — just to make things even less intuitive and easy for humans to parse. Our application needs don’t have to get very complicated at all before our code starts to look like this:
You might say, “Well, that’s a pain in the ass, but it works, doesn’t it? Why not just suck it up and code? Our runtime environment doesn’t care if our code is pretty or ugly, does it? Why should we?”
We should care. This set up means that our code is not portable — when we ask for data, we have to manage absolutely everything we want to do with it in a single callback function. It’s not easy to set up a chain of tasks that are all inter-reliant on one another, and it still isn’t easy to manage the order of operations.
This ugliness is not just cosmetic. It has a real, negative impact on how well your code works, and how well you and your team will be able to put it together and maintain it. If it was all we had, we’d make do, but…what if there were a better way?
Part II: The Land Not Before Promises
Spoiler alert: there is. So, what is a promise, and how will it get us out of the mess that we’ve apparently been making all this time?
The A+ specification defines a promise this way: A promise represents the eventual result of an asynchronous operation. The primary way of interaction with a promise is through its then method, which registers callbacks to receive either a promise’s eventual value or the reason why the promise cannot be fulfilled.
Did that make any sense to you? Don’t worry, like I said, it’s a chump’s game. Instead, let’s talk about what a promise does. A promise is basically a container for an asynchronous action — what this tells us is that, at a minimum, promises have to give us everything in terms of capability that vanilla async callbacks give us, because a promise is something that is taking a vanilla async callback and putting it into a format that will be less heinous to actually implement. Otherwise, there would be no point in having them — remember how we were talking early about how this is all in the name of making our jobs easier?
Promises are going to free us entirely from nesting. Promises are going to give us a really easy to follow breadcrumb trail of inter-reliant actions. Promises are going to give us multiple options for handling errors all at once, or customizably, all in formats that are easier to keep track of than what we have now. Promises are going to go a long way towards making the asynchronous, non-blocking code that we write just as predictable and unidirectional as the synchronous, “fast” blocking code that we’re used to writing. Which is pretty cool. How do we do that?
So. What is a promise? A promise is literally just a plain old object.
This is not any different than any object you’d make yourself (check out that list and see how many other coders have done just that). It’s an object created by a constructor, that has a bunch of useful properties and methods on it. Essentially, there are four things that make a promise a promise:
1. A promise takes an executor. This is the thing. The thing we actually need to get done. An executor is some kind of function that we’re using, whose return results we need to control asynchronously.
2. A promise has an internal state (which we cannot access or alter directly). Every promise starts out having a state of “pending.” Only once, in it’s whole “lifespan” can it change. It can either change from pending to fulfilled (yay!), or from pending to rejected (boo!). Once our promise state has switched, it cannot become pending again. It also can’t go from fulfilled to rejected, or from rejected to fulfilled. We only get one shot at mutating each promise. Which is a good thing! The state of a promise is going to determine which, if any, sets of callback functions we run (those we tag as error-case, or those that we tag as success-case). Once a promise has a non-pending state, that’s a fact on which we can rely for the entire future of our code.
3. A promise has a value. This value in a success case will be the data we want, or an error (or a “reason”) if things go wrong. The internal state of a promise will always resolve in tandem with ETIHER an error or the data as the promise’s value, and once that state has changed, not only is the state immutable, but the promise’s value is too. The value can’t change once we get it (at least, in relation to the promise itself). Which again, is a desirable state of affairs — we don’t want things shifting under our feet.
4. Finally, for a promise to be a promise, it has to have access to a “then” method (and we’re going to talk about this in more depth in a sec). This is what will take the place of nesting callbacks — it’s where we’ll handle the results of our asynchronous action.
Part III: Making Promises
More frequently, however, we’ll see that promises are supported and baked into other libraries that our application uses. Sequelize, for example, which helps us set up our database schema and saves us from the terrifying task of writing our own SQL queries, supports promises with its database actions, so we can just treat any of those actions as though they are variables representing a promise (because they are). Which is pretty great.
And we’re only just getting up to the real magic of Promises. Every promise has a method on it called “then.” And it’s easiest to understand what then does in very plain, english-language terms. If you have a promise, it will settle (that is, resolve or reject), and THEN run the code that is passed as a callback to the then function. And even more handily, the function that we pass to then automatically receives as an argument the resulting data or error.
But it doesn’t just stop there! Having only one then available to us isn’t much good. We talked earlier about how inter-reliant different operations might be, and so, for promises to be really helpful, they need to be chainable from one another. The return value of a then function is itself a new promise, so the following then will receive the resolution or error from that upstream promise as its argument.
Suddenly, we can control both when our functions run (or at least, we can guarantee that reliantNewPromise won’t run until somePromise resolves and passes on its value, and that weCouldDoThisAllDayPromise won’t run until that resolves, and so forth and so on) and guarantee that each will have the information which they need in order to run effectively. And again — it’s pretty easy on the eyes, isn’t it?
Part IV: We all make mistakes. Or, at least, errors.
One of the most annoying things about our old frenemies, vanilla async callbacks, is that PITA error-first format. Error management with promises is much, much simpler. First of all, promises have a success-first format. So one way to handle errors in a promise chain is to pass our then a second function argument, so that our program knows what to do in any possible outcome.
But promises give us something that async callbacks don’t — they give us bubbling. If one promise rejects, and the then it is currently in does not have an error case callback, it will move to the next then, and so on, until it finds the first then that does have an error case callback. So, another, more readable way to manage errors in promises is to have two separate thens, with the error handling then following the success handling one, where we would just pass “null” as the first argument.
Just to be clear — a promise “decides” which case to run based on its internal state. While the state is “pending” (forever, if the promise never resolves for some reason), no callbacks of any kind will be run. Once the state has settled, EITHER the success handlers will fire, OR the error ones will. There’s no middle ground, here, no circumstances under which both success and error handlers will execute. This is why it’s so important that the promise state can only change once, and only in certain predictable ways.
Part V: The Bottom Line
If you look at the chains from these examples, you can start to see that promises let us control our code more functionally, and allow us to have success and error bubbling in something like the form we have in a synchronous context. Better yet, it’s become much easier to manage and understand what data we have access to when. And we’re not just restricted to this pipeline of impenetrable bubbles where we get one piece of data, and move on to the next in a use it or lose it sort of style. We also have methods like Promise.all(), where we can aggregate the results of multiple promises, and handle all the data they return together — meaning, we can access them all at once, if we want to.
Let’s take a look at a quick example here:
Let’s tie this up with a nice bow. Promises give us simple chaining in the place of endless nesting — our code becomes linear again, the way we like it. They give us simple, unified error handling. They make our asynchronous actions portable — we can set them up in one place, and manage what to do when they complete in another, and they’re timing ambivalent, so we can even attach more handlers (or handler chains!) to them whenever or wherever we want, still confident that every appropriate success or error handler function will run.
If it seems like the promise-y meat of this went by more quickly than the build up, if promises seem less weighty than using vanilla async callbacks, then that’s good! That’s a sign of success here, because they are simpler, from a user standpoint. That’s why we generally will want to use them wherever we can in place of vanilla async.
Unfortunately, promises don’t solve all of our problems. They still keep our slow-gotten data in this sort of separate funnel within our program. We don’t have a simple way of making the information from a resolved promise available globally in our application, or of guaranteeing that code not in the promise chain won’t try to use our promise-y data before it’s settled. For that, you’re gonna have to wait until I get around to writing an article on async await!