Making a promise to understand javascript promises
By Joao Paulo Lindgren
TLDR;
- In the beginning of this post, I try to give a small context, of why promises were introduced in the javascript and what they are
- Next, I propose to build a promise from scratch to understanding more deeply how it works. If you want to go directly to there click here
- If you want simply to see the code, or clone it, take a look at the github repo
In the earlier days when the gods created the javascript, they created as a non-blocking language relying heavily on callbacks to run the background I/O operations. Using just one thread, javascript running on browser/node can call a Web API or a SO thread passing a function (callback) that should be called when the job is done, or when an error happens.
Callbacks work fine and make node very responsive since it does not block its only thread. So why we need promises? Well, the great problem with callbacks is when complexity increases and we need to have nested callbacks. You probably already seen this piece of code
Of course, the image is a joke, but in real life, if you have multiple nested callbacks things can get pretty wild.
Promises to the rescue
To solve this problem, promises were created. Following the joke of the last picture, this is how that code would look like with promises.
1const promisifyLoad = (disk) =>
2 new Promise(resolve => floppy.load(disk, data => resolve(data)));
3const promisifyPrompty = (message) =>
4 new Promise(resolve => floppy.prompt(message, () => resolve()));
5}
6
7promisifyLoad('disk1')
8 .then(promisifyPrompty('Please insert disk 2'))
9 .then(promisifyLoad('disk2'))
10 .then(promisifyPrompty('Please insert disk 3'))
11 .then(promisifyLoad('disk3'))
12 .then(promisifyPrompty('Please insert disk 4'))
13 .then(promisifyLoad('disk5'))
14 .then(() => {
15 // if node.js would have existed in 1995 with promises!
16 })
In essence, a promise is a proxy to a future value that you still do not have. It is just it, a javascript object with certain properties to follow. No black magic behind it. Some key behaviors of this “object” promise we can check in the specification.
http://www.ecma-international.org/ecma-262/6.0/#sec-promise-constructor
To get a better understanding of how a promise works, we are going to implement a promise object that I´ll call BastardPromise using the key aspects of a javascript promise.
- A promise should be created with an argument called executor. This argument must be a function and cannot be undefined.
- A new promise should have a “pending” state, should have an empty queue of fulfilled and rejected reactions, and should call the executor argument passing the its resolve, reject function.
- It can only be fulfilled or rejected and can be resolved only once.
- The methods then, catch, and finally should return a new promise.
Then
method should receive an onFulFill and onReject arguments and return a new promise. If the previous promise is fulfilled it should resolve the value. If it is pending, it should enqueue it to be resolved later.Catch
method is just a wrapper forthen(_, onRejected).
Finally
should return a new promise and must be called regardless of whether the promise is fulfilled or rejected.
I am assuming you are familiar with es6 syntax, how to create a new node project and how to create/run tests.
Implementing the bastard promise
We are going to use Jest to do our tests, but because we are reimplementing something that already exists, you can use a lib called Scientist for node to guide us. This is a cool lib ported from ruby that let you match an old behavior with a new behavior, it is really good for refactorings for example. In our case, we could, for example, create tests to check if the behaviors of the native javascript Promise matches our BastardPromise behavior.
First of all, create an empty class called BastardPromise
and export it.
1class BastardPromise {}
2
3module.exports = BastardPromise;
Next, create a test to define our constructor behavior.
1const BastardPromise = require("./bastard-promises");
2
3describe("Bastard Promises", () => {
4 describe("Initialization", () => {
5 it("should be created in a pending state", () => {
6 const promise = new BastardPromise(() => {});
7 expect(promise.state).toBe("pending");
8 });
9
10 it("should be created with empty chained arrays", () => {
11 const promise = new BastardPromise(() => {});
12 expect(promise.fulFillReactionChainQueue).toEqual([]);
13 expect(promise.rejectReactionChainQueue).toEqual([]);
14 });
15
16 it("should be created with undefned value", () => {
17 const promise = new BastardPromise(() => {});
18 expect(promise.innerValue).toBeUndefined();
19 });
20
21 it("should throw exception if executor is not a function", () => {
22 expect(() => new BastardPromise({ data: "hi" })).toThrow(TypeError);
23 });
24 });
25});
Now, we are going to implement our first piece of code in the BastardPromise
.
1class BastardPromise {
2 constructor(executor) {
3 if (typeof executor !== "function") throw new TypeError(`Promise resolver ${executor} is not a function`);
4
5 this.state = "pending";
6 this.fulFillReactionChainQueue = [];
7 this.rejectReactionChainQueue = [];
8 this.innerValue = undefined;
9 }
10}
- Our constructor receives a function as an argument and checks if it is a function. Later we are calling this function in our constructor.
- The state is set to “pending”.
- Set the fulFillReactionChainQueue/rejectReactionChainQueue to empty. This queue will serve to hold references for resolve, reject values of promises that are not resolved yet.
- Set promise value to undefined.
By the time, we should have all of our tests passing. If you are new to node, you can run jest -watchAll to the tests run automatically, or in the VSCODE you can install jest test explorer to also run automatically your tests.
To effectively resolve our promise value, we will need to implement a resolve and reject methods. Every time we create a promise, we need to pass a function (the executor argument). As soon as our promise is created it will trigger this function passing as arguments its resolve and reject function. The executor function should call resolve (to fulfill the promise), reject (to reject the promise), or return another promise.
The BastardPromise resolve
method will be responsible to check if the promise is not pending, set its state to fulfilled, set its value, and call all the fulFillChain queue to resolve pending promises in the chain.
The reject
method is similar to the resolve, except that we set the state as rejected. Also, we are going to trigger the pending reject chains.
Finally, with these two methods, we can call the executor function in the constructor passing both the resolve and the reject methods as arguments.
1class BastardPromise {
2 constructor(executor) {
3 if (typeof executor !== "function") throw new TypeError(`Promise resolver ${executor} is not a function`);
4
5 this.state = "pending";
6 this.fulFillReactionChainQueue = [];
7 this.rejectReactionChainQueue = [];
8 this.innerValue = undefined;
9
10 executor(this.resolve, this.reject);
11 }
12
13 resolve = (value) => {
14 if (this.state !== "pending") return;
15
16 this.state = "fulfilled";
17 this.innerValue = value;
18
19 for (const onFulFilled of this.fulFillReactionChainQueue) onFulFilled(this.innerValue);
20 };
21
22 reject = (error) => {
23 if (this.state !== "pending" return;
24
25 this.state = "rejected";
26 this.innerValue = error;
27
28 for (const onRejected of this.rejectReactionChainQueue) onRejected(this.innerValue);
29 };
30}
To avoid check the state against magic strings every time, we can create a promise-state.js
to encapsulate the states.
1const state = {
2 PENDING: "pending",
3 FULFILLED: "fulfilled",
4 REJECTED: "rejected",
5};
6
7module.exports = state;
After that, we can check and set the state using state.PENDING
for instance.
Now we are able to create tests to check if our promise will be resolved/rejected correctly.
1it("should be able to be resolved", () => {
2 const value = "success";
3 const resolvedPromise = new BastardPromise((resolve) => resolve(value));
4 expect(resolvedPromise.state).toBe(state.FULFILLED);
5 expect(resolvedPromise.innerValue).toBe(value);
6});
7
8it("should be able to be rejected", () => {
9 const error = new Error("promise rejected");
10 const rejectedPromise = new BastardPromise((_, reject) => reject(error));
11 expect(rejectedPromise.state).toBe(state.REJECTED);
12 expect(rejectedPromise.innerValue).toBe(error);
13});
Once again, our tests should be all green.
Adding thennable methods chain
We are ready for the next step. One of the key aspects of a promise is the capability of being chained and resolve other chains inside it.
The main method responsible for that behavior is the then
method. Although is not hard, it is probably the hardest party to gasp in our promise object.
Let’s write the tests for some behaviors.
1describe("chainning behavior", () => {
2 describe("when fulFilled", () => {
3 it("should throw exception if then onFulFill is not a function", () => {
4 expect(() => new BastardPromise((resolve) => resolve("data")).then(null)).toThrow(
5 new TypeError("onFulFill is not a function")
6 );
7 });
8
9 it("should resolved value be passed to then", (done) => {
10 return new BastardPromise((resolve) => resolve({ data: 777 })).then(({ data }) => {
11 expect(data).toBe(777);
12 done();
13 });
14 });
15
16 it("should thennable methods be chained", (done) => {
17 return new BastardPromise((resolve) => resolve({ data: 777 }))
18 .then(({ data }) => {
19 expect(data).toBe(777);
20 return data + 223;
21 })
22 .then((res) => {
23 expect(res).toBe(1000);
24 done();
25 });
26 });
27
28 it("should async resolved value be passed to then", (done) => {
29 return new BastardPromise((resolve) => setTimeout(() => resolve({ data: 777 }), 5)).then(({ data }) => {
30 expect(data).toBe(777);
31 done();
32 });
33 });
34 });
35});
Of course, our tests should be broken right now. With these tests we defined some key behaviors of our then
method. The first one is explicit from the specification. The onFulFill argument should be a function. The second key behavior is the capability of chain multiple “thens”, resolving values in each of them.
The last test is important to check the ability to deal with async values. Instead of a function that resolves a value right way, we use a setTimeout to simulate an API call for example, and call resolve only on the callback.
Let’s dive into the code to check how we accomplish all these things.
1then = (onFulFill) => {
2 if (typeof onFulFill !== "function") throw TypeError("onFulFill is not a function");
3 return new BastardPromise((resolve) => {
4 const onFulFilled = (value) => resolve(onFulFill(value));
5
6 if (this.state == state.FULFILLED) onFulFilled(this.innerValue);
7 else this.fulFillReactionChainQueue.push(onFulFilled);
8 });
9};
- The first thing will be to check if the argument received (onFulFill) is a function.
- Second, we create a new promise passing an executor function that receives a resolver.
- Inside this executor function, we created a function that resolves the onFulFill argument from the
then
method. - This function can take two paths. If the current promise is resolved
this.state == state.FULFILLED
, we resolve the onFulFill passing the current promise value synchronously. - If the current promise is still pending, means that it is being resolved, so we add to the current fulFillReactionChainQueue to be resolved lately asynchronously.
If you are not experienced with javascript, the tricky part here is the notion that we are in a promise A, creating the promise B. And, although the executor is being called in the constructor of the promise B, the this
is a closure referencing promise A. So, in the end, we check the state and add in the queue of the Promise A.
On the other hand, the resolve
function is from promise B (passed as an argument to the executor), so when we enqueue the onFulFilled in the promise A, we are in fact enqueueing the resolve of the promise B. If sounds confusing, I encourage you to debug the code to get a better understand, you can even create unique ids to the promises to help you.
If everything is green move to the catch part. The tests and the implementation.
1const getErrorObject = (message = "Promise has been rejected") => new Error(message);
2describe("when rejected", () => {
3 it("should catch", (done) => {
4 const actualError = getErrorObject();
5 new BastardPromise((resolve, reject) => reject(actualError)).catch((err) => {
6 expect(err.message).toBe(actualError.message);
7 done();
8 });
9 });
10
11 it("should catch and then", (done) => {
12 const actualError = getErrorObject();
13 new BastardPromise((_, reject) => reject(actualError))
14 .catch((err) => {
15 expect(err.message).toBe(actualError.message);
16 return { data: "someData" };
17 })
18 .then(({ data }) => {
19 expect(data).toBe("someData");
20 done();
21 });
22 });
23});
Some basics scenarios of the catch are capturing the exception threw in previous promises, and “renew” the promise chain. That last part means that we can put a then after a catch, and in case the catch returns a value, it would be passed to the next then.
The catch implementation is something like this.
1catch = (onReject) => {
2 return new BastardPromise((resolve, reject) => {
3 const onRejected = (value) => resolve(onReject(value));
4
5 if (this.state == state.REJECTED) onRejected(this.innerValue);
6 else this.rejectReactionChainQueue.push(onRejected);
7 });
8};
The catch
method is similar to the then
method, but look if the state is rejected to decide if it will resolve now or later the onReject argument.
Notice, that we pass the result of the onReject call to the resolve
method, and not to the reject method. This is because the properties of the catch is to “renew” the promise. So the value is resolved and the promise is set to fulfilled again.
Take advantage of tests to fix bug and refactoring
Hurray! We have a basic promise feature! We have made great progress until here, but if you have a keen eye, you´ll notice several things.
First, there are some bugs. For example, if we have a rejected promise, followed by a then, and a catch our promise is getting stuck. Another bug will happen if we return another promise inside of a then for example.
Another bug that we may find, is when our onFulFill
or the onReject
arguments passed to the then
/catch
method throws an exception, in that case, the correct behavior would be to reject the promise instead of let the code throw the exception.
Also, I´ll take the opportunity to make some refactorings. For example, our then
method to follow the specification can receive not only the onFulFill as argument, but also an onReject argument. If we implement then like this, we can turn our catch into just a wrapper for then(_, onReject)
.
Let’s get to work! I hope that you already got the feeling of how things work internally, so I´ll speed up things a little bit from now on.
Adding the tests for the bugs that we found.
1describe("when fulFilled", () => {
2 ... already existent tests
3
4 it("should support chain of promises on which outside promises are returned", (done) => {
5 const outsidePromise = new Promise((resolve) => setTimeout(() => resolve({ file: "photo.jpg" }), 10));
6
7 return new BastardPromise((resolve) => {
8 setTimeout(() => resolve({ data: "promise1" }), 10);
9 })
10 .then((response) => {
11 expect(response.data).toBe("promise1");
12 return outsidePromise;
13 })
14 .then((response) => {
15 expect(response.file).toBe("photo.jpg");
16 done();
17 });
18 });
19});
20describe("when rejected", () => {
21 ... already existent tests
22
23 it("should allow catch after a then with resolved promise", (done) => {
24 const errorMessage = "Promise has been rejected";
25 const thenFn = jest.fn();
26
27 return new BastardPromise((resolve, reject) => {
28 setTimeout(() => reject(new Error(errorMessage)), 10);
29 })
30 .then(thenFn)
31 .catch((error) => {
32 expect(error.message).toBe(errorMessage);
33 expect(thenFn).toHaveBeenCalledTimes(0);
34 done();
35 });
36 });
37
38 it("should allow catch after a then with resolved promise", (done) => {
39 const errorMessage = "Promise has been rejected";
40 const thenFn = jest.fn();
41
42 return new BastardPromise((resolve, reject) => {
43 setTimeout(() => reject(new Error(errorMessage)), 10);
44 })
45 .then(thenFn)
46 .catch((error) => {
47 expect(error.message).toBe(errorMessage);
48 expect(thenFn).toHaveBeenCalledTimes(0);
49 done();
50 });
51 });
52
53 it("should catch if returned inner promise is rejected", (done) => {
54 const errorMessage = "an error ocorred";
55 const anotherPromise = new BastardPromise((resolve, reject) => reject(new Error(errorMessage)));
56 return new BastardPromise((resolve) => {
57 resolve("promise1");
58 })
59 .then((response) => {
60 expect(response).toBe("promise1");
61 return anotherPromise;
62 })
63 .catch(({ message }) => {
64 expect(message).toBe(errorMessage);
65 done();
66 });
67 });
68
69 it("should catch on then reject callback", (done) => {
70 const thenResolveFn = jest.fn();
71 const renewedData = { year: 1984 };
72 new Promise((resolve, reject) => reject("just an error"))
73 .then(thenResolveFn, (err) => {
74 expect(err).toBe("just an error");
75 return renewedData;
76 })
77 .then((res) => {
78 expect(thenResolveFn).toHaveBeenCalledTimes(0), done();
79 expect(res).toBe(renewedData);
80 done();
81 });
82 });
83});
Our revamped BastardPromise
would be something like:
1const state = require("./promise-states");
2class BastardPromise {
3 constructor(executor) {
4 if (typeof executor !== "function") throw new TypeError(`Promise resolver ${executor} is not a function`);
5
6 this.state = state.PENDING;
7 this.chainReactionQueue = [];
8 this.innerValue = undefined;
9
10 executor(this.resolve, this.reject);
11 }
12
13 resolve = (value) => {
14 if (this.state !== state.PENDING) return;
15
16 if (value != null && typeof value.then === "function") {
17 const result = value.then(this.resolve.bind(this), this.reject.bind(this));
18 return result;
19 }
20
21 this.state = state.FULFILLED;
22 this.innerValue = value;
23
24 for (const { onFulFilled } of this.chainReactionQueue) onFulFilled(this.innerValue);
25 };
26
27 reject = (error) => {
28 if (this.state !== state.PENDING) return;
29
30 this.state = state.REJECTED;
31 this.innerValue = error;
32
33 for (const { onRejected } of this.chainReactionQueue) onRejected(this.innerValue);
34 };
35
36 then = (onFulFill, onReject) => {
37 if (typeof onFulFill !== "function") throw TypeError("onFulFill is not a function");
38
39 return new BastardPromise((resolve, reject) => {
40 const onRejected = this.getOnRejectedAction(resolve, reject, onReject);
41 const onFulFilled = this.getOnFulFilledAction(resolve, reject, onFulFill);
42
43 if (this.state == state.FULFILLED) onFulFilled(this.innerValue);
44 else if (this.state == state.REJECTED) onRejected(this.innerValue);
45 else this.chainReactionQueue.push({ onFulFilled, onRejected });
46 });
47 };
48
49 catch = (onReject) => this.then(() => {}, onReject);
50
51 resolveOrFallback(resolve, reject) {
52 try {
53 resolve();
54 } catch (err) {
55 reject(err);
56 }
57 }
58
59 getOnRejectedAction = (resolve, reject, onReject) => {
60 return (value) => {
61 this.resolveOrFallback(() => (onReject != null ? resolve(onReject(value)) : reject(value)), reject);
62 };
63 };
64
65 getOnFulFilledAction = (resolve, reject, onFulFill) => {
66 return (value) => {
67 this.resolveOrFallback(() => resolve(onFulFill(value)), reject);
68 };
69 };
70}
71
72module.exports = BastardPromise;
- In the constructor, we changed the two arrays, one for fulfilled and others for rejected reactions to only one. This is not strictly necessary, but I think the code is cleaner and with less information to confuse the reader.
- To fix the test that we return a promise inside the
then
, we changed theresolve
method to identify if the value is a promise (we check for the then method), in case true, we resolve that promise. - The
then
method concentrates most of the changes. Now it allows to the caller send two functions as arguments. One when is fulfilled and the other when the promise is rejected. Now we are in compliance with the specification and fix the bug when a promise is rejected followed by a then and catch (test case should allow catch after a then with resolved promise). - We encapsulate the callback methods
onFulFilled
,onRejected
in thethen
method to be executed inside a try/catch. In case an exception is thrown, we reject the promise on the catch. - Because then is allowed to receive a onReject, our catch now is only a wrapper for a
then(() => {}, onReject)
call.
In the end, we should have all our tests passing.
That’s it, our promise is basically completed. In the next section as a bonus, we are going to implement static ´all´ and ´race´ methods because they are very common and important in a lot of use cases.
As an overview, promises are just proxy objects that work with callbacks to let you chain future values.
If you are wondering what is the difference to Async/Await, it is basically nothing. Async/Await is just a syntax sugar over the promises to let you write async code in a synchronous way.
#TODO elaborate with async/await x promises
[Bonus] Implementing all and race methods
A very common use case is when you have multiple promises and want to wait to all o them complete to do some work, or you need to respond only to the fastest resolved one. To accomplish that, we need to implement these two static methods. As we did before, we will start writing the tests.
1describe("static methods", () => {
2 describe("promise all", () => {
3 it("should wait untill all promise are resolved and pass array of results to then", (done) => {
4 const p1 = new BastardPromise((resolve) => resolve({ data: "syncData" }));
5 const p2 = new BastardPromise((resolve) => setTimeout(resolve({ data: "async" }), 100));
6 return BastardPromise.all([p1, p2]).then((results) => {
7 expect(results).toContainEqual({ data: "syncData" });
8 expect(results).toContainEqual({ data: "async" });
9 done();
10 });
11 });
12
13 it("should reject if one promise fail", (done) => {
14 const thenFn = jest.fn();
15 const p1 = new BastardPromise((_, reject) => setTimeout(() => reject("error"), 10));
16 const p2 = new BastardPromise((resolve) => resolve("async"), null, "p2");
17 return BastardPromise.all([p1, p2])
18 .then(thenFn)
19 .catch((err) => {
20 expect(err).toBe("error");
21 expect(thenFn).toHaveBeenCalledTimes(0);
22 done();
23 });
24 });
25 }); //all
26
27 describe("promise race", () => {
28 it("should return first promise to resolve", (done) => {
29 const p1 = new BastardPromise((resolve) => resolve({ data: "syncData" }));
30 const p2 = new BastardPromise((resolve) => setTimeout(resolve({ data: "async" }), 10));
31 return BastardPromise.race([p1, p2]).then((result) => {
32 expect(result).toEqual({ data: "syncData" });
33 done();
34 });
35 });
36
37 it("should fulfill if the first promise is resolved despite other errors", (done) => {
38 const resolvedObject = { data: "resolvedWithSuccess" };
39 const promise1 = new BastardPromise((resolve) => setTimeout(() => resolve(resolvedObject), 5));
40 const promise2 = new BastardPromise((resolve, reject) => setTimeout(() => reject({ data: "async2" }), 20));
41
42 return BastardPromise.race([promise1, promise2]).then((result) => {
43 expect(result).toEqual(resolvedObject);
44 done();
45 });
46 });
47
48 it("should reject if the first promise is rejected", (done) => {
49 const actualError = getErrorObject();
50 const promise1 = new Promise((resolve) => setTimeout(() => resolve("resolve"), 20));
51 const promise2 = new Promise((_, reject) => setTimeout(() => reject(actualError), 5));
52
53 return Promise.race([promise1, promise2]).catch((error) => {
54 expect(error.message).toBe(actualError.message);
55 done();
56 });
57 });
58 }); // race
59}); // static methods
- The
all
method must return a new promise and wait until all the promises passed as an array argument be fulfilled to pass to resolve the next then. - The
all
method returned promise should be rejected if just one promise passed as argument is rejected. - The
race
method must return a new promise, that will be resolved with the value fo the first resolved promise passed as an argument to it. - The
race
method returned promise should be rejected if the first promised to resolve of the array passed as argument is rejected.
The all
and race
implementation.
1static all = (promises) => {
2 let results = [];
3 const reducedPromise = promises.reduce(
4 (prev, curr) => prev.then(() => curr).then((p) => results.push(p)),
5 new BastardPromise((resolve) => resolve(null))
6 );
7 return reducedPromise.then(() => results);
8};
9
10static race = (promises) =>
11 new Promise((res, rej) => {
12 promises.forEach((p) => p.then(res).catch(rej));
13 });
[Bonus 2] Implementing finally method!
I completely forgot about the finally
method! Altough it was not present in since ecmascript 6 like the other methods it is a easy one to implement and a very nice feature to have in our promise!
The finally
is also a chainable method that returns a new promise and is always called no matter the promise is fulfilled or rejected. Also, the finally
method does not receive a value from the previous promise.
1it("should then and call finally afterall", (done) => {
2 new BastardPromise((resolve) => setTimeout(() => resolve({ data: 777 }), 5))
3 .then(({ data }) => expect(data).toBe(777))
4 .finally((res) => {
5 expect(res).toBeUndefined();
6 done();
7 });
8});
9
10it("should catch and call finally afterall", (done) => {
11 new BastardPromise((resolve, reject) => reject("just an error"))
12 .catch((err) => expect(err).toBe("just an error"))
13 .finally((res) => {
14 expect(res).toBeUndefined();
15 done();
16 });
17});
1finally = (onFinally) => {
2 return new BastardPromise((resolve) => {
3 const onFinalized = () => resolve(onFinally());
4 if (this.state == state.FULFILLED) onFinalized();
5 else this.finallyChainReactionQueue.push(onFinalized);
6 });
7 };
Do not forget to initialize the finallyChainReactionQueue as an empty array in the constructor.
You can access the complete code here
References
Very nice video series from Waldemar Neto talking about promises. Unfortunately or fortunately depending on what language do you speak is recorded in Portuguese.
https://www.youtube.com/watch?v=CcL2WZRvROQ
Very nice article also showing how to implement a promise.
https://thecodebarbarian.com/write-your-own-node-js-promise-library-from-scratch.html
Nice article talking about Tasks, microtasks, queues, and schedules. If you read this article, you will understand a difference from our promise to the native promise. The native promise executes its function putting into the microtask queue even if it is synchronous. https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/