One of the best ways to understand a concept or implementation is by building it up from scratch. Let's take this approach to gain a greater understanding of JavaScript promises.
First, well give a brief introduction / refresher to what Promises are and how to use them, then move onto an analysis of how to create one from scratch, notes on implementation with code examples, and finally the full implementation with some test cases.
A Brief Intro to JavaScript Promises
Before proceeding to how to create a Promise from scratch, let's briefly review what a Promise is and how it works. A Promise is an object which handles an asynchronous task, provided by the consumer, and allows consumers to respond to the completion or failure of that task. In a more technical sense, it is a proxy for a value which might not yet be known when the promise is created. Promises will always be in a state of pending
, fulfilled
, or rejected
. When the fulfilled
or rejected
state is reached, the consumer can use the emitted value directly or use it as a part of a chain involving multiple promises.
The code below shows one common way of chaining promises:
The code below shows another way of chaining promises in a more implicit manner:
Implementation Analysis
In order to make our custom Promise object behave in the same way as JavaScript's built-in Promise, we'll need to:
- Implement the constructor so the injected constructor callback invokes resolve and reject.
- Allow any number of
.then
and.catch
callbacks to be added. - Return a new promise when
.then
and.catch
is invoked. - Implement a
.finally
method which will be invoked regardless of the final state of the Promise. - Ensure the Promise correctly invokes resolve & reject + all callbacks registered.
All of the points above refer to handling callbacks in different ways. We will need to be careful in how we accept, store, and later invoke callbacks we receive at each point. In our implementation here, we will invoke the constructor function immediately, but store all other callbacks to be invoked at a later point.
Handling Callbacks
In our implementation here, we will initialize empty arrays for callbacks of three types:
- Resolve: callbacks registerd with
.then
- Reject: callbacks registered with
.catch
- Finally: callbacks registered with
.finally
When each function, such as .then
, is invoked, we will:
- Return a new custom Promise object with the logic below wrapped in:
- Push a new wrapper-callback in one of our callback arrays. The wrapper-callback will:
- Run the original callback provided by the consumer and resolve / reject with that value.
- Push another wrapper-callback in the other callback array to handle the other scenario.
- For example, on
.catch
, pass a callback for reject and resolve so the value is piped through either way.
- For example, on
The code below represents a partial implementation of the custom Promise object. It creates the empty callback arrays, then on each .then
type method, follows the logic above. With this logic, our Promise object will handle the callbacks and chain Promises appropriately. Notice that then .then
and .catch
have slightly different logic when handling the callback provided by the user, but the overall logic is the same.
Invoking the Constructor Callback
Unlike the other callbacks, the constructor callback must be invoked immediately (synchronously). In the example below, we can see that the console logging provided in the constructor callback logs to the console before the statement immediately following it:
Therefore, in our custom Promise object, we will need to invoke the callback, which we are calling the initializer
, immediately. In order to do so, we will need to have a callback for resolve
and reject
ready to pass into that initializer function. We will define methods _resolve
and _reject
on the class itself, then wrap them in callbacks which will be passed into the initializer function. Those two class methods will invoke all relevant pending callbacks: resolve
callbacks upon resolve and reject
callbacks upon reject. In any case, all finally
callbacks will be invoked.
Putting the Pieces Together
When the two parts, constructor invokation and .then
callback handling, are put together, the main parts of the Promise implementation will be complete. In the code here, promise.mjs contains all of the code discussed above, plus a few other things not explicitly mentioned, and tests.mjs has a few different test cases to ensure the core functionality is working properly. There are certain thing snot included, suc has Promise.race
, but for the purposes of this example, our implementation covers the main points.
Note: if you look through the test cases, you may observe that we are using async / await
with our own custom Promise class. Even though we are not using the built-in Promise, we can still take advantage of async and await because our Promise is still a thenable: something with a .then
method. All Promises are thenables, but not all thenables are Promises.
More on Promises
A more complete specification of the internal workings of Promises, along with browser compatability, can be found on Mozilla's Developer Site. Note that all modern major browsers do support Promises, but there may be slight variations in coverage for certain Promise methods.
Full Code Implementation
Below, we'll showcase the full code contents which brings everything together: