Everyone knows that to create a new Promise, you need to define it this way:
new Promise((resolve, reject) => {
...
resolve(someValue)
})
You pass a callback that defines the specific behavior of your promise.
A Promise is a container that provides an API to manage and transform a value, but this value has the particularity of not being there yet. It will exist, but in the future.
Using containers to wrap values is a very common pattern of functional programming. There are different kinds of these containers, the most famous being Functors and Monads.
class Promise {
constructor(then) {
this.then = then
}
}
const getPeople = new Promise((resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
getPeople.then(renderPeople, console.error)
Nothing incredible here, this doesn't do more than any function with a
success (resolve
) and an error (reject
) callback, but wait!
we are not done yet.
For now, our implementation of a Promise looks a little too simple to be true, right? There is a feature we are not covering yet:
We can chain several .then
and it will return a new Promise every time. this is where the implementation of
Promise.then
gets messy. It combines too many features.
let's split these features into several methods to keep the code cleaner. What we'd like to build is a method that transforms the value contained in the promise and returns a new Promise. Does it remind you of something?
Array.prototype.map
does exactly that. In the Hindley-Milner type notation, .map
's type signature is:
map :: (a -> b) -> Array a -> Array b
This signature means that it takes a function transforming a type a
to a type b
,
let's say a String
to a Boolean
, then takes an Array of a
(String
) and finally returns
an Array of b
(Boolean
).
let's build a Promise.prototype.map
with almost the same type signature:
map :: (a -> b) -> Promise a -> Promise b
class Promise {
constructor(then) {
this.then = then
}
map(mapper) {
return new Promise((resolve, reject) => this.then(
x => resolve(mapper(x)),
reject
))
}
}
Well first you can see we are returning a new Promise:
map(mapper) {
return new Promise(...)
}
That's cool. Now let's look at the function we pass to this new Promise.
First of all, remember that this function is actually the .then
method of our
Promise. it won't execute until we call it. We are just defining
what it does.
So what's inside?
(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 fails. 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
callback 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 up, we are overriding our resolve
function with a compositon of our mapper function and the next resolve
.
This will pass our x
value to the mapper, and resolve the Promise with the returned value.
const getPeople = new Promise((resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
getPeople
.map(JSON.parse)
.map(json => json.data)
.map(people => people.filter(isMale))
.map(people => people.sort(ageAsc))
.then(renderMales, console.error)
We can chain them! Our code is composed of small, simple functions.
This is why functional programmers love currying. With curried functions, we coud write:
getPeople
.map(JSON.parse)
.map(prop('data'))
.map(filter(isMale))
.map(sort(ageAsc))
.then(renderMales, console.error)
which is arguably cleaner. this is also more confusing when you are not used to it.
To better understand what is happening here, let's explicitly define how the
.then
method get transformed at each .map
call:
new Promise((resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(body)
})
})
then = (resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(body)
})
}
.map(JSON.parse)
then = (resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body))
})
}
.map(x => x.data)
then = (resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data)
})
}
.map(people => people.filter(isMale))
then = (resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data.filter(isMale))
})
}
.map(people => people.sort(ageAsc))
then = (resolve, reject) => {
HTTP.get('/people', (err, body) => {
if (err) return reject(err)
resolve(JSON.parse(body).data.filter(isMale).sort(ageAsc))
})
}
.then(renderMales, console.error)
HTTP.get('/people', (err, body) => {
if (err) return console.error(err)
renderMales(JSON.parse(body).data.filter(isMale).sort(ageAsc))
})
there is yet another thing that .then
does for us. When you return another
promise within the .then
method, it waits for it to resolve
to pass the resolved value to the next .then
inner function. so .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.map(person => getFriends(person))
// => Promise(Promise([Person]))
getPerson.flatMap(person => getFriends(person))
// => Promise([Person])
so let's build this flatMap method:
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)
Pretty cool, right?
so what we did here by separating the different behaviors of a promise
is to create a Monad. Nothing scary about it, a monad is just 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 sometimes referred to as chain
or bind
. I find flatMap
to be more explicit.
The "Promise" API we built is called a Task
in some languages, and its .then
method is often
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 Tasks are lazy and a
Promise is eager. This means that our code doesn't really do anything
until you call the fork
/.then
method. on a Promise, however, even if you
create it and never call .then
, the inner function will execute right
away.
just by separating the three behaviors of .then
, and by making it lazy,
we implemented in 20 lines of code a 400+ lines polyfill.
Not bad right?
If you want to know more about the real world Task implementation, you can check out the folktale repo.
- Promises are really just a containers holding values, just like Arrays.
.then
has three different behaviors and this is why it can be confusing- It executes the inner callback of the promise right away.
- It composes a function which takes the future value of the Promises and transforms it to return a new Promise containing the transformed value.
- if you return a Promise within a
.then
method, it will flatten it to avoid nested Promises.
- Promises compose your functions for you.
- why is composition good?
- Separation of concerns. It encourage developers to make small functions that do only one thing, and are therefore easy to understand and reuse.
- why is composition good?
- Promises abstract away the fact that you are dealing with asynchronous values. A Promise is an object that you can pass around in your code, just like any other 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, every monad is a reification of some computational context.
Great explanation! There are not a lot of material that analyses promises this way. Thank you for sharing!