Reimplementing Promises in order to Understand Them
Introduction
If you've worked with any JavaScript in the past several years, you've probably heard of the concept of asynchronous programming through Promises
. Promises are quite important in JavaScript because they provide a much easier way to program asynchronously than previous methods.
Before Promises: Callbacks
Callbacks are the simplest type of asynchronous execution, both to implement and understand. Callbacks are implemented by passing a function to another function - the passed function is called a callback function
The problem with callbacks is code style - callbacks are hard to maintain because they lead to nesting. Specifically, code ends up in a nested structure due to the fact that callbacks are frequently placed within other callbacks:
Specifically, this makes callbacks very hard to use in code, because they conform poorly to the problem asynchronous code usually needs to solve - performing a bunch of tasks in series as a "thread" without interrupting any other "thread" in progress. For example, a web app may need to send a request to a database, read a file, and then send a request to a remote web server before responding to the client. While it is waiting for the database, filesystem, or remote service to respond, it should be able to handle processing for other requests.
Side-note: Middleware
Interestingly enough, the Express server framework for Node.JS contains a callback-based framework that attempts to solve similar problems to what promises solve. This is known as middleware. In the "middleware" pattern, one object handles the order in which steps are completed, and passes a "next()" function, as well as a "local variables" object, to each of the functions as the callback which causes the object to execute the next function in the list.
In a middleware-based approach, the above code would become:
In the above case, the next() function handles calling the next middleware function in the list of middleware passed to onRequest(). This allows the three functions to be called sequentially on one object asynchronously, without having to nest callbacks.
Here's a example implementation of the onRequest() function, which allows middleware functions to execute in a sequence before calling a callback function:
Promises
Promises introduce a much simpler and easier way to handle this logic by introducing an object known as the Promise object. A Promise represents data that isn't currently available, but should be available at a future time. It can call a specific callback or set of callbacks on completion.
Important rules that promises obey:
- Always-once: if a callback is attached to a function, then it must always be called once.
- Chaining: promises should fit well with the idea of doing a bunch of asynchronous tasks in sequence, which means doing multiple tasks in sequence and handling errors should not require any sort of "nesting"
- Compatibility with callbacks: for any system that uses callbacks, it should be simple to create a "promises" style wrapper for such a system so that tasks implemented in the "promises" style can interact with tasks implemented in the "callback" style.
Implementing Promises
With that out of the way, let's reimplement promises, step-by-step:
1. Promises represent an event (success/failure of task) that happens once, and they call event listeners upon the task's completion
Under the hood, Promise objects represent a task that is either in progress or completed, and they call a certain set of event listeners upon the task's completion. In order to add event listeners to a Promise, we can use the .then(<l>)
and .catch(<l>)
methods, both of which accept an event listener. The .then()
function accepts two event listeners (both are optional), the first of which is called on success and the second of which is called on failure. The catch method is equivalent to calling .then()
with only the second argument. Promises pass the result of the task they represent to the callback function passed to the .then()
method.
Using this, let's implement a promise for the opening of a file. In Node.JS, there is a function called fs.open()
, which is a callback-based approach to opening a file. We can build on this API to implement a promise-based version.
2. There is an API for converting callback-based functions into Promise-based functions
Doing the above is quite a bit of effort to put in every time we implement a Promise-based API - we had to create an entire new class every time. It turns out that we don't actually need to do this because there is an API to transform a callback-based function into a promise-based function.
The way this API works is by creating special callbacks that we pass to the callback-based function, the resolve() and reject() callback (as they are usually named). Calling the resolve() or reject() functions with the return value of the function being converted into a Promise (or simply even making them callbacks of the callback-based function).
Here's an example of a Promise-based version of the fs.open()
function implemented using the aforementioned API:
We can update our Promise class from above to support this style of implementation:
3. Chaining using the .then() function
One of the most powerful features of JavaScript promises is heir ability to be chained together, allowing you to perform a sequence of asynchronous operations readably. Promises facilitate chaining through the .then()
method. Each .then()
call returns a new Promise, which enables chaining multiple operations together seamlessly.
When using .then()
in this manner, data can also be sent through each step. For example, if a promise is returned from the first .then()
callback, the promise returned by .then()
resolves to this value, and the next .then()
callback (which acted on that promise) will receive this value as it's next argument.
In order to add chaining to our existing Promise class, we must update the .then()
function to return a Promise. The .then()
function must follow the following rules:
- If the current Promise is rejected, the onRejected callback should be executed. If the callback returns a value, a new Promise should be returned from that value.
- If the onFulfilled callback throws an error: the newly returned Promise should also be rejected with the same reason.
- If the onFulfilled callback is not provided: the newly returned Promise should be fulfilled with the same value as the current Promise.
- If the current Promise is rejected, the onRejected callback should be executed. If the callback returns a value, a new Promise should be returned with that value.
- If the onRejected callback throws an error: the newly returned Promise should be fulfilled with the same value.
- If the onRejected callback is not provided: the newly returned Promise should be rejected with the same reason as the current Promise.
Here's the Promise implementation above with promise chaining implemented:
4. Promises cannot resolve to other Promises
While we have the basics of nesting handled, we can't actually execute anything asynchronously in a .then()
callback yet. In order to get around this, we must be able to do something asynchronously in this callback without having to pass a different callback to our .then()
callback. We do this using another rule: Promises may not resolve to other Promises. This allows us to return an asynchronously evaluated value (a Promise) from our .then()
callback function, and then have the rest of the chain wait for this value before executing.
We need to rewrite our current implementation of resolve() in our Promise to resolve any Promise passed to it.
5. async/await
A final feature related to promise is JavaScript's async/await. Async/await let's us wait for a promise inside of a function, as long as we configure
As async/await is a JavaScript language feature that requires special support by the JavaScript runtime itself, we can't reimplement it ourselves. It turns out, however, that JavaScript will let us use async/await with our own Promises too, as long as we implement .then()
correctly.
Conclusion
Given these features, we now have a mostly-complete implementation of the Promises spec! Hope that this helped you learn a little more about how Promises work as a web feature.