Promise implementation

Promise is a general concept to handle the asynchronize development in javascript. It's not hard to use, but when it comes with some other concepts, like react's hook, its internal chain etc. It always takes me some time to think through it. Especially when check the code of some open-source libraries, find the way they use Promise is quite fancy and also hard to understand, which reminders me that I am still stay on the level of using, far away from deep understanding.

Inspired by this blog, I think it's a good idea to write promise by myself. It's not only because it's not that hard and complex, it can also help me in the future work when I meet it again. Thesedays, function programing is quite popular in js development, but object-orient coding is still used in lots of scenarioes. So I would like to try to implement it with both function and class, even though javascript's prototype is almost equal to class concept, which was introduced in ES2015. It can also train these basic skills again. I would deploy them in my github and write blogs to record it.

Thanks to promise/A+, we get the requirement analysis, clear logic and library to test the solution.

Steps

According to the Promise/A+'s requirements, I think it would be four steps:

  1. Basic then function -- 1.1, 1.2
  2. Fulfill Promise and then parameters -- from 2.1 to 2.2.5
  3. Return a Promise in then -- 2.2.6, 2.2.7
  4. resolve function -- 2.3

Basic thenable

According to the Terminology, the first two are promise and thenable. The former should take a function (an executor) as parameter which would take two functions (resolve and reject) as parameters as well. Check Higher-order functions. These resolve and reject would be used in the executor and defined in the then's parameters. Let's simply implement this thenable first.

1function Promise(executor) {
2  let self = this;
3  self.executor = executor;
4}
5
6Promise.prototype.then = function (onFulfilled, onRejected) {
7  let self = this;
8  self.executor(onFulfilled, onRejected);
9};

As we see, we just delay the execution of executor from Promise to then and the fulfill and reject functions are used in executor while defined or passed in then.

We can use below code to test it. NB: do not use arrow function in definition, because arrow function does not own this. If we use this inside of it, it would refer to the outer function. Check here.

1const a = new Promise((resolve) => setTimeout(() => resolve("result"), 100));
2
3a.then((data) => console.log("Data", data));
4// console.log -> Data: result

But obviously here, we only have one executor and the parameters are not validated. Besides executor runs in the then, not in the Promise. Currently, the code is simple, but it would cause problems. We will mention this below, let's go further.

Fulfill Promise and then parameters

Validate then parameters

2.2.1 Both onFulfilled and onRejected are optional arguments:
   2.2.1.1 If onFulfilled is not a function, it must be ignored.
   2.2.1.2 If onRejected is not a function, it must be ignored.

onFulfilled and onRejected should be validated:

1let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
2let rejectFunc = isFunction(onRejected)
3  ? onRejected
4  : (e) => {
5      throw e;
6    };

Update state

After validating the parameters, we can implement the point 2.2.2 and 2.2.3. Translate here:

If onFulfilled/onRejected is a function,
   it must be called after promise is fulfilled/rejected, with promise's value/reason as its first argument;
   it must not be called before promise is fulfilled/rejected;
   it must not be called more than once;

So we need to set the state and pass the value or reason to related functions. To do this, I wrap the validated functions and update internal states inside of them:

 1// 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
 2function resolve(value) {
 3  if (self.state === STATE.PENDING) {
 4    // 2.2.2.2 it must not be called before promise is fulfilled.
 5    self.state = STATE.FULFILLED;
 6    self.value = value;
 7    fulfillFunc(value);
 8  }
 9}
10
11// 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
12function reject(err) {
13  if (self.state === STATE.PENDING) {
14    // 2.2.3.2 it must not be called before promise is rejected.
15    self.state = STATE.REJECTED;
16    self.value = err;
17    rejectFunc(err);
18  }
19}

Asynchronized execution

According to the first point 2.2.4 refering NOTE 3.1, we should execute the fulfill and reject functions asynchronously, which is the main reason for people using it. In javascript, we can utilize setTimeout.

1setTimeout(() => fulfillFunc(value), 0);

Return Promise in then

Before we continue implementing the rest requirement, we need to reorganize the codes first. We need to move the executor from then to the constructor of Promise.

Why? One main reason is we will execute the executor multiple times if it's in then, since one promise can have multiple thens. This is definitely unacceptable because we only need to execute executor once.

So til now, the Promise function looks like this:

 1function Promise(executor) {
 2  let self = this;
 3
 4  // set the state as pending, 2.1.1
 5  self.state = STATE.PENDING;
 6
 7  // 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
 8  function resolve(value) {
 9    if (self.state === STATE.PENDING) {
10      // 2.2.2.2 it must not be called before promise is fulfilled.
11      self.state = STATE.FULFILLED;
12      self.value = value;
13      setTimeout(() => resolveFunc(value), 0);
14    }
15  }
16
17  // 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
18  function reject(err) {
19    if (self.state === STATE.PENDING) {
20      // 2.2.3.2 it must not be called before promise is rejected.
21      self.state = STATE.REJECTED;
22      self.value = err;
23      setTimeout(() => rejectFunc(err), 0);
24    }
25  }
26
27  try {
28    // executor is function whose parameters is resolve and reject functions,
29    // which would be called inside of executor.
30    if (isFunction(executor)) executor(resolve, reject);
31  } catch (err) {
32    reject(err);
33  }
34}

And you may notice we use the function resolveFunc and rejectFunc, but we haven't define them. They would work together with the requirement of multiple calling of then.

Call then mutiple times 2.2.6

2.2.6 then may be called multiple times on the same promise.

So all the respective fulfill/reject functions should be called in the order of original calls. Obviously, we can apply a queue here. Create a callback queue, add then's onFulfilled and onRejected parameters to it and handle it when fulfilled/rejected. And this is how we handle the above resolveFunc and rejectFunc.

 1function resolve(value) {
 2  ...
 3  // 2.2.6.1
 4  setTimeout(
 5    () => self.callback.forEach(({ resolveFunc }) => resolveFunc(value)),
 6    0
 7  );
 8}
 9
10function reject(err) {
11  ...
12  // 2.2.6.2
13  setTimeout(
14    () => self.callback.forEach(({ rejectFunc }) => rejectFunc(err)),
15    0
16  );
17}

When Promise is fulfilled/rejected, we would execute all the related functions in the queue. And obviously, we have to push the callback functions to the queue in then when the state is still pending.

 1Promise.prototype.then = function (onFulfilled, onRejected) {
 2  let self = this;
 3
 4  // 2.2.1 Both onFulfilled and onRejected are optional arguments, if any is not function, must ignore it
 5  let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
 6  let rejectFunc = isFunction(onRejected)
 7    ? onRejected
 8    : (e) => {
 9        throw e;
10      };
11
12  switch (self.state) {
13    // if the state is fulfilled or rejected, just execute the related function and pass the result to the resolvePromise
14    case STATE.FULFILLED:
15    case STATE.REJECTED:
16      return setTimeout(() => {
17        try {
18          let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
19          func(self.value);
20        } catch (e) {
21          rejectFunc(e);
22        }
23      }, 0);
24    case STATE.PENDING:
25      // if it's still pending, push the resolve/reject to callback queue. All the callback functions would be executed once state are changed
26      return self.callback.push({
27        resolveFunc: () => {
28          try {
29            fulfillFunc(self.value);
30          } catch (e) {
31            rejectFunc(e);
32          }
33        },
34        rejectFunc: () => {
35          try {
36            rejectFunc(self.value);
37          } catch (e) {
38            rejectFunc(e);
39          }
40        },
41      });
42  }
43};

Return Promise 2.2.7

Here comes the difficult part, then should return a Promise which would support the chaining feature.

then must return a promise [3.3].
   promise2 = promise1.then(onFulfilled, onRejected);

After reading other implementations, here comes a question. Would this promise2 have its own executor? Yes or no would have different implementations.

Let's first implement the simple one - Yes. It would have its own resolve/reject functions in executor. I would implement the optimized one -- No, with an empty promise, in another blog.

Another thing we need to think of is what if the value returned by promise2 is a promise. This is what the 2.3 Promise Resolution Procedure would do. Let's preserve this to later chapter and assume the value returned by promise2 is NOT another promise Then the logic of this promise2's executor should be:

  1. If the state is fulfilled, the value returned by the onFulfilled function should be passed to resolve2 function in promise2's executor
  2. If the state is rejected, the value returned by the onRejected function should be passed to reject2 function in promise2's executor
  3. If the state is still pending, pass the resolveFunc and rejectFunc which would call resolve2 and reject2, to callback queue
  4. Any exception throwed by onFulfilled or onRejected should be handled by reject2

And we can extract the process of handling self.value as a function to reuse code.

 1function handleResult(resolve2, reject2) {
 2  return () => {
 3    try {
 4      // 2.2.7.1, 2.2.7.2
 5      let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
 6      let func2 = self.state == STATE.FULFILLED ? resolve2 : reject2;
 7      func2(func(self.value));
 8    } catch (e) {
 9      reject2(e);
10    }
11  };
12}

Til now, we have implement the features

  1. Return Promise in then to support chaining
  2. Multiple then of one Promise

The codes should be like this:

 1const STATE = {
 2  PENDING: Symbol.for("pending"),
 3  FULFILLED: Symbol.for("fulfilled"),
 4  REJECTED: Symbol.for("rejected"),
 5};
 6
 7const isFunction = (func) => func && typeof func === "function";
 8const isObject = (arg) => arg && typeof arg === "object";
 9
10function Promise(executor) {
11  let self = this;
12
13  // set the state as pending, 2.1.1
14  self.state = STATE.PENDING;
15
16  self.callback = [];
17
18  // 2.2.2.1 onFulfilled must be called after promise is fulfilled, with promise’s value as its first argument.
19  function resolve(value) {
20    if (self.state === STATE.PENDING) {
21      // 2.2.2.2 it must not be called before promise is fulfilled.
22      self.state = STATE.FULFILLED;
23      self.value = value;
24      // 2.2.6.1
25      setTimeout(
26        () => self.callback.forEach(({ resolveFunc }) => resolveFunc(value)),
27        0
28      );
29    }
30  }
31
32  // 2.2.3.1 onRejected must be called after promise is rejected, with promise’s reason as its first argument.
33  function reject(err) {
34    if (self.state === STATE.PENDING) {
35      // 2.2.3.2 it must not be called before promise is rejected.
36      self.state = STATE.REJECTED;
37      self.value = err;
38      // 2.2.6.2
39      setTimeout(
40        () => self.callback.forEach(({ rejectFunc }) => rejectFunc(err)),
41        0
42      );
43    }
44  }
45
46  try {
47    // executor is function whose parameters is resolve and reject functions,
48    // which would be called inside of executor.
49    if (isFunction(executor)) executor(resolve, reject);
50  } catch (err) {
51    reject(err);
52  }
53}
54
55Promise.prototype.then = function (onFulfilled, onRejected) {
56  let self = this;
57
58  // 2.2.1 Both onFulfilled and onRejected are optional arguments, if any is not function, must ignore it
59  let fulfillFunc = isFunction(onFulfilled) ? onFulfilled : (value) => value;
60  let rejectFunc = isFunction(onRejected)
61    ? onRejected
62    : (e) => {
63        throw e;
64      };
65
66  function handleResult(resolve2, reject2) {
67    return () => {
68      try {
69        // 2.2.7.1, 2.2.7.2
70        let func = self.state == STATE.FULFILLED ? fulfillFunc : rejectFunc;
71        let func2 = self.state == STATE.FULFILLED ? resolve2 : reject2;
72        func2(func(self.value));
73      } catch (e) {
74        reject2(e);
75      }
76    };
77  }
78
79  return new Promise((resolve2, reject2) => {
80    switch (self.state) {
81      // if the state is fulfilled or rejected, just execute the related function and pass the result to the resolvePromise
82      case STATE.FULFILLED:
83        return setTimeout(handleResult(resolve2, reject2), 0);
84      case STATE.REJECTED:
85        return setTimeout(handleResult(resolve2, reject2), 0);
86      case STATE.PENDING:
87        // if it's still pending, push the resolve/reject to callback queue. All the callback functions would be executed once state are changed
88        return self.callback.push({
89          resolveFunc: handleResult(resolve2, reject2),
90          rejectFunc: handleResult(resolve2, reject2),
91        });
92    }
93  });
94};

Let's simply test it:

 1const p1 = new Promise((resolve, reject) => {
 2  setTimeout(() => resolve("resolved first one"), 3000);
 3});
 4
 5p1.then((res) => {
 6  console.log("then1: ", res);
 7  return res;
 8}).then((res) => {
 9  setTimeout(() => console.log("then2: ", res), 1000);
10});
11
12p1.then((res) => {
13  console.log("another then: ", res);
14});
15
16// then1:  resolved first one
17// another then:  resolved first one
18// then2:  resolved first one

Promise Resolution Procedure 2.3

Here comes the last step, implement the Promise Resolution Procedure. According to the description of requirement:

This treatment of thenables allows promise implementations to interoperate, as long as they expose a Promises/A+-compliant then method. It also allows Promises/A+ implementations to “assimilate” nonconformant implementations with reasonable then methods.

And before we begin to implement, let's think of why we have to have this resolvePromise, since it calls itself inside recursively. Comparing the text explanation, let me present one test case from Promise/A+. The case is generated by two loops, I just pick one here.

Adapter

In the test case, an adapter is utilized and explained in the Promise/A+ as well, check adapter;

 1Promise.defer = Promise.deferred = function () {
 2  let dfd = {};
 3  dfd.promise = new Promise((resolve, reject) => {
 4    dfd.resolve = resolve;
 5    dfd.reject = reject;
 6  });
 7  return dfd;
 8};
 9
10adapter.resolved = function (value) {
11  var d = adapter.deferred();
12  d.resolve(value);
13  return d.promise;
14};

Definitely, we have seen this resolved many times, but what it does exactly? Through its code, we can understand it

  1. Create a promise with a very simple executor which normally executes the logic of asynchronous codes
  2. Resolve the promise with the value immediately by updating state and saving the value before we have this resolve method
  3. Return this created promise

So for the code resolved(value), actually it just preserves the value and waiting the resolve method from its then. When its then is called, the value would be passed to resolve method immediately.

Test case

The test case's description is

y is an already-fulfilled promise for a synchronously-fulfilled custom thenable, then calls resolvePromise synchronously

And I can simplify the test code, removing all the wrapped test functions:

 1const result = { result };
 2
 3var promise = resolved({ dummy }).then(function onBasePromiseFulfilled() {
 4  return {
 5    then: function (resolvePromise) {
 6      resolvePromise(
 7        resolved({
 8          then: function (onFulfilled) {
 9            onFulfilled(result);
10          },
11        })
12      );
13    },
14  };
15});
16
17promise.then(function onPromiseFulfilled(value) {
18  assert.strictEqual(value, result);
19  done();
20});

Yes, HHHHHHHHeadache!!!!

Definitely there are a lot of promises wrapped like matryoshka doll, and so hard to dig into it. Yes, I know, but we can analysis the codes step by step, at leas we can simply count how many promises are here:

  1. resolved({ dummy }) uses resolved. As explained above, it returns a resolved promise and waits for the resolve method from its then. Let's call this promise as promise-TEMP;
  2. promise-TEMP called then which passes the function onBasePromiseFulfilled as resolve. AND it returns our first promise -- promise
  3. onBasePromiseFulfilled return a thenable object as value of promise. Let's call it x;
  4. x, which we can simply treat it as a Promise as well -- promise2, has the then function which would call its parameter resolvePromise further. The called value would be the value of promise2. Let's call it y;
  5. The value passed to resolvePromise is another resolved promise -- promise3, which is fulfilled and has no resolve method. But its value is another thenable object -- or promise4;
  6. Finally we reach the bottom level, the resolve method onFulfilled of promise4's then calls the result;

So let's count how many promises and values we have here (ignore the promise-TEMP):

  1. promise -- the only one having name in our test codes
    1. its value x -> promise2
  2. promise2 -- first thenable object
    1. its value y -> promise3
  3. promise3 -- a resolved promise
    1. its value -> promise4
  4. promise4 -- second thenable object
    1. its value -> result, an object

So we can see the value of promise is a promise -- promise2 which wraps another two promises -- promise3 and promise4. And the assert sits inside of the resolve onPromiseFulfilled method of the first promise, it would expect the value returned by onPromiseFulfilled to be the same with result, which is the value of promise4.

We can conclude some points here:

  1. The thenable object can be treated as a promise, which means they can be handled as the same codes. (This is not a principle, we can discuss this in another blog)
  2. If the value of a promise is a thenable object, the promise's resolve/reject methods would be passed to the value until the final value is not a promise and handled by the original resolve/reject methods.

Implementation

OK, it's enough to learn from the test case, even though it's what I learned after I passed all the test cases. Let's go back to the requirements and implement it.

Since thenable object can be treated as promise, we would ignore the requirement of 2.3.2:

2.3.2 If x is a promise, adopt its state [3.4]:
    2.3.2.1 If x is pending, promise must remain pending until x is fulfilled or rejected.
   2.3.2.2 If/when x is fulfilled, fulfill promise with the same value.
   2.3.2.3 If/when x is rejected, reject promise with the same reason.

This point can be merged with 2.3.3. Others would not be hard, just follow the steps:

 1function resolvePromise(promise, x, resolve2, reject2) {
 2  if (promise == x) {
 3    return reject2(
 4      new TypeError("Resolved result should not be the same promise!")
 5    );
 6  } else if (x && (isFunction(x) || isObject(x))) {
 7    let called = false; // 2.3.3.3.3
 8
 9    try {
10      let then = x.then;
11
12      if (isFunction(then)) {
13        then.call(
14          // 2.3.3.3
15          x,
16          function (y) {
17            if (called) return; // 2.3.3.3.3
18            called = true;
19            return resolvePromise(promise, y, resolve2, reject2); // 2.3.3.3.1
20          },
21          function (r) {
22            if (called) return;
23            called = true;
24            return reject2(r); // 2.3.3.3.2
25          }
26        );
27      } else {
28        resolve2(x); // 2.3.3.4
29      }
30    } catch (err) {
31      if (called) return; // 2.3.3.3.4.1
32      called = true;
33      reject2(err); // 2.3.3.3.4.2
34    }
35  } else {
36    resolve2(x); // 2.3.4
37  }
38}

Test

Now we have implemented all the mandatory codes, we can run the test the codes with npm lib promises-aplus-tests.

1npm i -g promises-aplus-tests
2promises-aplus-tests promise1.js // promise1.js is the file name

The full code can be checked here. There are different versions with different techniques, you can choose anyone.

Summary

This solution is not perfect and it just simply follows the rules of Promise/A+ without any better architecture and design. There are a lot of good solutions which restructures the codes with their own idea and logic. I would rewrite the promise with other techniques later.

Besides, this version just completes the basic part. Promise also has resolve, catch, finally, etc. I will implement them as well and write another article about them.

Thanks to this article, which inspires me to make decision to implement the Promise and Zhi Sun's article, which reminders me of taking steps to implement the hard part.

---------------------- UPDATE ----------------------

Instead prototype, I have implemented the Promise with javascript class, which is not the feature of ES5, therefore, we need to compile it with babel and test with nodejs. And the code logic is almost the same with the version 1. Here is the code.

There are two things we need to take care when using class:

  1. When we pass the #internalResolve/#internalReject functions which are defined as class private methods, to executor, we need to use bind to bind the function with class's instance or this, since we use this inside of #internalResolve/#internalReject. Codes are:
 1constructor(executor) {
 2  try {
 3    executor(
 4      this.#internalResolve.bind(this),
 5      this.#internalReject.bind(this)
 6    );
 7  } catch (err) {
 8    this.#internalReject(err);
 9  }
10}
11
12#internalResolve(value) {
13  if (this.#state == STATE.PENDING) {
14    // ...
15  }
16}
17
18#internalReject(reason) {
19  if (this.#state == STATE.PENDING) {
20    //...
21  }
22}
  1. Be careful for this especially when we create functions inside of class methods. It's best to define a variable and assign this to it, like self.
comments powered by Disqus