Did you know I have a newsletter? 📬
If you want to get notified when I publish new blog posts or
make major project announcements, head over to
https://cleancodestudio.paperform.co/
Promises, under the hood
Today, we create our own JavaScript Promise implementation [From Scratch].
To create a new promise we simply use new Promise
like so:
new Promise((resolve, reject) => {
...
resolve(someValue)
})
We pass a callback that defines the specific behavior of the promise.
A promise is a container:
- Giving us an API to manage and transform a value
- That lets us manage and transform values that are not actually there yet.
Using containers to wrap values is common practice in the functional programming paradigm. There are different kinds of "containers" in functional programming. The most famous being Functors and Monads.
Implementing a promise to understand its internals
1. The then()
method
class Promise
{
constructor (then)
{
this.then = then
}
}
const getItems = new Promise((resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
getItems.then(renderItems, console.error)
Pretty straight forward, this implementation so far doesn't do anything more than any function with a success (resolve
) and an error (reject
) callback.
So check it, when we're making a promise from the ground up we have an extra - normally non-revealed - step to implement.
2. Mapping
Currently, our Promise implementation won't work - it's over simplified and doesn't contain all of the required behavior needed to properly work.
What is one of the features and/or behaviors our implementation is currently missing?
For starters, we're not able to chain .then()
calls.
Promises can chain several .then()
methods and should return a new Promise each time when the result from anyone of these .then()
statements is resolved.
This is one of the primary feature that makes promises so powerful. They help us escape callback hell.
This is also the part of our Promise implementation we are not currently implementing. It can get a bit messy combining all of the functionalities needed to make this Promise chain work properly in our implementation - but we got this.
Let's dive in, simplify, and set up our implementation of a JavaScript Promise to always return or resolve an additional Promise from a .then()
statement.
To start with, we want a method that will transform the value contained by the promise and give us back a new Promise.
Hmmm, doesn't this sound oddly familiar? Let's take a closer look.
Aha, this sounds exactly like how Array.prototype.map
implements pretty to the mark - doesn't it?
.map
's type signature is:
map :: (a -> b) -> Array a -> Array b
Simplified, this means that map takes a function and transforms type a
to a type b
.
This could be a String to a Boolean, then it would take an Array of a(string) and return an Array of b(Boolean).
We can build a Promise.prototype.map
function with a very similar signature to that of Array.prototype.map
which would allow us to map our resolved promise result into another proceeding Promise. This is how we are able to chain our .then's
that have callback functions that return any random result, but then seem to magically somehow return Promises without us needing to instantiate any new promises.
map :: (a -> b) -> Promise a -> Promise b
Here's how we implement this magic behind the scenes:
class Promise
{
constructor(then)
{
this.then = then
}
map (mapper)
{
return new Promise(
(resolve, reject) =>
this.then(x => resolve(mapper(x)),
reject
)
)
}
}
What'd we just do?
Okay, so let's break this down.
- When we create or instanciate a Promise, we are defining a callback that is our then callback aka used when we successfully resolve a result.
- We create a map function, that accepts a mapper function. This map function returns a new promise. Before it returns a new promise it attempts to resolve the results from the prior promise using. We
map
the results from the prior Promise into a new Promise and then we are back out within the scope of the newly created promise instantiated within our our map method.
- We create a map function, that accepts a mapper function. This map function returns a new promise. Before it returns a new promise it attempts to resolve the results from the prior promise using. We
- We can continue this pattern, appending as many
.then
callbacks as we need to and always returning a new Promise without us needing to externally instantiate any new promises outside of ourmap
method.
- We can continue this pattern, appending as many
(resolve, reject) => this.then(...))
What is happening is that we are calling this.then
right away. the this
refers to our current promise, so this.then
will give us the current inner value of our promise, or the current error if our Promise is failing. We now need to give it a resolve
and a reject
callback :
// next resolve =
x => resolve(mapper(x))
// next reject =
reject
This is the most important part of our map function. First we are feeding our mapper
function with our current value x
:
promise.map(x => x + 1)
// The mapper is actually
x => x + 1
// so when we do
mapper(10)
// it returns 11.
And we directly pass this new value (11
in the example) to the resolve
function of the new Promise we are creating.
If the Promise is rejected, we simply pass our new reject method without any modification to the value.
map(mapper) {
return new Promise((resolve, reject) => this.then(
x => resolve(mapper(x)),
reject
))
}
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve(10), 1000)
})
promise
.map(x => x + 1)
// => Promise (11)
.then(x => console.log(x), err => console.error(err))
// => it's going to log '11'
To sum it up, what we are doing here is pretty simple. we are just overriding our resolve
function with a compositon of our mapper function and the next resolve
.
This is going to pass our x
value to the mapper and resolve the returned value.
Using a bit more of our Promise Implementation:
const getItems = new Promise((resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
getItems
.map(JSON.parse)
.map(json => json.data)
.map(items => items.filter(isEven))
.map(items => items.sort(priceAsc))
.then(renderPrices, console.error)
And like that, we're chaining. Each callback we chain in is a little dead and simple function.
This is why we love currying in functional programming. Now we can write the following code:
getItems
.map(JSON.parse)
.map(prop('data'))
.map(filter(isEven))
.map(sort(priceAsc))
.then(renderPrices, console.error)
Arguably, you could say this code is cleaner given you are more familiar with functional syntax. On the other hand, if you're not familiar with functional syntax, then this code made be extremely confusing.
So, to better understand exactly what we're doing, let's explicitly define how our .then()
method will get transformed at each .map
call:
Step 1:
new Promise((resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
Step 2: .then
is now:
then = (resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(body)
})
}
.map(JSON.parse)
.then
is now:
then = (resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body))
})
}
Step 3:
.map(x => x.data)
.then
is now:
then = (resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data)
})
}
Step 4:
.map(items => items.filter(isEven))
.then
is now:
then = (resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data.filter(isEven))
})
}
Step 6:
.map(items => items.sort(priceAsc))
.then
is now:
then = (resolve, reject) => {
HTTP.get('/items', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data.filter(isEven).sort(priceAsc))
})
}
Step 6:
.then(renderPrices, console.error)
.then
is called. The code we execute looks like this:
HTTP.get('/items', (err, body) => {
if (err) return console.error(err)
renderMales(JSON.parse(body).data.filter(isEven).sort(priceAsc))
})
3. Chaining and flatMap()
Our Promise implementation is still missing something - chaining.
When you return another promise within the .then
method, it waits for it to resolve and passes the resolved value to the next .then
inner function.
How's this work? In a Promise, .then
is also flattening this promise container. An Array analogy would be flatMap:
[1, 2, 3, 4, 5].map(x => [x, x + 1])
// => [ [1, 2], [2, 3], [3, 4], [4, 5], [5, 6] ]
[1, 2 , 3, 4, 5].flatMap(x => [x, x + 1])
// => [ 1, 2, 2, 3, 3, 4, 4, 5, 5, 6 ]
getPerson.flatMap(person => getFriends(person))
// => Promise(Promise([Person]))
getPerson.flatMap(person => getFriends(person))
// => Promise([Person])
This is our signature breakdown, but if it's tough to follow I'd recommend trying to track down the logic tail a few more times and if it doesn't click then attempt diving into the direct implementation below. We're pretty deep and without experience in functional programming this syntax can be tricky to track, but give it your best go and let's go on in below.
class Promise
{
constructor(then)
{
this.then = then
}
map(mapper)
{
return new Promise(
(resolve, reject) => this.then(
x => resolve(mapper(x)),
reject
)
)
}
flatMap(mapper) {
return new Promise(
(resolve, reject) => this.then(
x => mapper(x).then(resolve, reject),
reject
)
)
}
}
We know that flatMap
's mapper function will return a Promise. When we get our value x, we call the mapper, and then we forward our resolve and reject functions by calling .then
on the returned Promise.
getPerson
.map(JSON.parse)
.map(x => x.data)
.flatMap(person => getFriends(person))
.map(json => json.data)
.map(friends => friends.filter(isMale))
.map(friends => friends.sort(ageAsc))
.then(renderMaleFriends, console.error)
How bout that :)
What we actually did here by separating the differing behaviors of a promise was create a Monad.
Simply, a monad is a container that implements a .map
and a .flatMap
method with these type signatures:
map :: (a -> b) -> Monad a -> Monad b
flatMap :: (a -> Monad b) -> Monad a -> Monad b
The flatMap
method is also referred as chain
or bind
. What we just built is actually called a Task, and the .then
method is usually named fork
.
class Task
{
constructor(fork)
{
this.fork = fork
}
map(mapper)
{
return new Task((resolve, reject) => this.fork(
x => resolve(mapper(x)),
reject
))
}
chain(mapper)
{
return new Task((resolve, reject) => this.fork(
x => mapper(x).fork(resolve, reject),
reject
))
}
}
The main difference between a Task and a Promise is that a Task is lazy and a Promise is not.
What's this mean?
Since a Task is lazy our program won't really execute anything until you call the fork
/.then
method.
On a promise, since it is not lazy, even when instantiated without its .then
method never being called, the inner function will still be executed immediately.
By separating the three behaviors characterized by .then
, making it lazy,
just by separating the three behaviors of .then
, and by making it lazy, we have actually implemented in 20 lines of code a 400+ lines polyfill.
Not bad right?
Summing things up
- Promises are containers holding values - just like arrays
-
.then
has three behaviors characterizing it (which is why it can be confusing)-
.then
executes the inner callback of the promise immediately -
.then
composes a function which takes the future value of the Promises and transforms so that a new Promise containing the transformed value is returned - If you return a Promise within a
.then
method, it will treat this similarly to an array within an array and resolve this nesting conflict by flattening the Promises so we no longer have a Promise within a Promise and remove nesting.
-
Why is this the behavior we want (why is it good?)
-
Promises compose your functions for you
- Composition properly separates concerns. It encourages you to code small functions that do only one thing (similarly to the Single Responsibility Principle). Therefore these functions are easy to understand and reuse and can be composed together to make more complex things happen without creating high dependency individual functions.
Promises abstract away the fact that you are dealing with asynchronous values.
A Promise is just an object that you can pass around in your code, just like a regular value. This concept of turning a concept (in our case the asynchrony, a computation that can either fail or succeed) into an object is called reification.
It's also a common pattern in functional programming. Monads are actually a reification of some computational context.
Clean Code Studio
Clean Code
JavaScript Algorithms Examples
JavaScript Data Structures
Did you know I have a newsletter? 📬
If you want to get notified when I publish new blog posts or
make major project announcements, head over to
Top comments (1)