Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Last active August 27, 2024 00:29
Show Gist options
  • Save gvergnaud/f354d70173daa828af49dc78dd4485ef to your computer and use it in GitHub Desktop.
Save gvergnaud/f354d70173daa828af49dc78dd4485ef to your computer and use it in GitHub Desktop.
Promises, under the hood.

Promises, under the hood

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.

Let's implement a Promise to better understand how it works internally

1. The then method

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.

2. Mapping

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
    ))
  }
}

What the f*ck is going on here?

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.

Now let's use it a little bit more:

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:

step 1

new Promise((resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
})
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(body)
  })
}

step 2

  .map(JSON.parse)
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (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('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data)
  })
}

step 4

  .map(people => people.filter(isMale))
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isMale))
  })
}

step 5

  .map(people => people.sort(ageAsc))
.then is now:
then = (resolve, reject) => {
  HTTP.get('/people', (err, body) => {
    if (err) return reject(err)
    resolve(JSON.parse(body).data.filter(isMale).sort(ageAsc))
  })
}

step 6

  .then(renderMales, console.error)
.then is called. The code we execute looks like this:
HTTP.get('/people', (err, body) => {
  if (err) return console.error(err)
  renderMales(JSON.parse(body).data.filter(isMale).sort(ageAsc))
})

3. Chain/FlatMap

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.

To sum it up

  • 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.

why are Promises good?

  • 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.
  • 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.
@gvergnaud
Copy link
Author

You're welcome! glad you like it

@salihbenlalla
Copy link

This is a great explanation of how promises work under the hood, thank you for sharing.

@askldd
Copy link

askldd commented Oct 30, 2022

Merci beaucoup pour ces explications! Très utile lorsque l'on a besoin de saisir les rouages d'un concept pour l'utiliser sereinement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment