February 11, 2020
This is the second part of "Promise/A+ implementation" series. If you don't know what it's all about, check out the first part here. Today we're going to make our skeleton of a promise comply to 2.1 and 2.2 part of the spec.
According to the 2.1 part of spec we need to make sure that once promise is settled, it must not
transition to any other state. Let's make our _transition
method handle this
requirement.
_transition = (state, value) => {// Don't transition if the promise is settledif (this.state !== states.pending) return;// Continue if it's notthis.state = state;this.value = value;}
Entirety of this section is dedicated to implementing famous then
method. It makes
it possible to compose asynchronous functions. It takes in two functions - onFulfilled
and onRejected
, none of which is required.
// Then can be called multiple times on the same Promiseconst p = HelloPromise.resolve(5);p.then(x => x*5).then(console.log);p.then(x => x*2).then(console.log);// logs 25 and 10
Seeing the above example it becomes pretty obvious that we have to somehow
track thens
that have been called on our promise object. But how? I propose that
we use an array, called callbacks
in which we will hold onFulfilled
and
onRejected
references. Each item of callback array will look like this:
let callback = {onFulfilled: [Function],onRejected: [Function]}
We need some place to store our callbacks. Let's add a callback array to our constructor.
class HelloPromise {constructor(executor) {// ...this.callbacks = [];// ...}// ...}
It would be nice to have a separate function for adding callbacks(more on this later):
_addCallback = callback => {this.callbacks.push(callback);}
Now let's make our then
method add new callback whenever it's called:
then = (onFulfilled, onRejected) => {this._addCallback({onFulfilled: onFulfilled,onRejected: onRejected,});}
According to the spec, onFulfilled
and onRejected
functions are optional and should
be ignored if they're not a function. We can handle it in a higher order function:
then = (onFulfilled, onRejected) => {const member = fn => value => {if (isFunction(fn)) return fn(value);}this._addCallback({onFulfilled: onFulfilled,onRejected: onRejected,});}
This part of the spec tells us that our callbacks should only be executed when
the promise has settled. Depending on state
, we will call a different function.
How to achieve this behaviour? We need to add a new method to our class -
_executeCallbacks
.
_executeCallbacks = () => {// if isn't settled, don't execute callbacksif (this.state === states.pending) return;// depending on state we execute a different methodconst member = this.state === states.resolved ? 'onFulfilled' : 'onRejected';while (this.callbacks.length) {// execute only onceconst callback = this.callbacks.shift();// with the value the promise settled withcallback[member](this.value);}}
Obviously we should fire callbacks on state transition:
_transition = (state, value) => {if (this.state !== states.pending) return;this.state = state;this.value = value;// execute callbacks when promise has settledthis._executeCallbacks();}
There's also a possibility that current Promises state has changed when we were
adding a new callback. Let's fire our callbacks in _addCallback
.
_addCallback = callback => {this.callbacks.push(callback);// Promise might have already settledthis._executeCallbacks();}
There's one problem though. What happens if we run our test suite right now? Among others, we fail at this test:
2.2.2.2: it must not be called before promise is fulfilled
(failed) fulfilled after a delay
How to solve this problem? In fact, we should execute our callbacks asynchronously.
Let's change our _executeCallbacks
method.
_executeCallbacks = () => {if (this.state === states.pending) return;const member = this.state === states.resolved ? 'onFulfilled' : 'onRejected';const fire = () => {while (this.callbacks.length) {const callback = this.callbacks.shift();callback[member](this.value);}}// execute callbacks asynchronouslyrunAsync(fire);}
Alright now we have to address the elephant in the room. What does then
method have to do with promises? 2.2.7 of the Spec says that then
should return a promise.
Let's call it promise2
.
If onFulfilled is not a function, we should simply resolve promise2
with the value
passed in.
If onRejected is not a function, we should reject promise2
with the reason
passed in.
Otherwise we try to resolve promise2
with the result of onFulfilled
or onRejected
functions(depends what
was called in _executeCallbacks).
If any error comes up we reject promise2
with this error.
Looking at the above requirements the most obvious thing
is that we have to be able to somehow transition the state of the second promise.
We could directly modify it's state by calling _transition
method.
I, however, really don't like directly calling methods that are not meant to be accessed outside of an object.
I have to admit - this is the part that initially gave me a headache. It's like Inception, but I don't like it. There are many ways to deal with it. I will present the one I came up with.
Let's take it step by step. First, make our then
method return a promise.
then = (onFulfilled, onRejected) => {...return new HelloPromise((resolve, reject) => null);}
From now on we will access internal methods of promise2
from the inside of executor
function, so
we need to move everything inside.
then = (onFulfilled, onRejected) => {return new HelloPromise((resolve, reject) => {const member = fn => value => { ... }return this._addCallback({onFulfilled: member(onFulfilled),onRejected: member(onRejected),})});}
Resolve promise2
with the value returned from our onFulfilled or onRejected.
then = (onFulfilled, onRejected) => {return new HelloPromise((resolve, reject) => {const member = fn => value => {// 2.2.7.1resolve(fn(value))}return this._addCallback({onFulfilled: member(onFulfilled),onRejected: member(onRejected),})});}
If resolving caused an error, reject promise2
with the error.
then = (onFulfilled, onRejected) => {return new HelloPromise((resolve, reject) => {const member = fn => value => {try {resolve(fn(value)); // 2.2.7.1}catch (reason) {reject(reason); // 2.2.7.2}}return this._addCallback({onFulfilled: member(onFulfilled),onRejected: member(onRejected),})});}
If onFulfilled
is not a function, resolve with the value passed in. If onRejected
is not a function, reject with the value passed in.
then = (onFulfilled, onRejected) => {return new HelloPromise((resolve, reject) => {const member = (fn, fallback) => value => {// 2.2.7.3 and 2.2.7.4// fallback will be either reject or resolveif (!isFunction(fn)) return fallback(value);try {resolve(fn(value)); // 2.2.7.1}catch (reason) {reject(reason); // 2.2.7.2}}return this._addCallback({onFulfilled: member(onFulfilled, resolve), // fallback = resolveonRejected: member(onRejected, reject), // fallback = reject})});}
Build and run the tests:
$ npm run build; npm test
See? HelloPromise is now compliant with 2.1 and 2.2 part of the Promises/A+ spec.
The only thing left is to properly implement our _resolver
method. Don't worry,
then
method is the most confusing part of this implementation, now our brains
can finally rest.
Source code for each part of this series is available here.
Part I: Promise/A+ Implementation
Coming soon ...