Another Perspective on Promises

I spent 9 months of this year working on a project using Java 8 and enjoyed it much more than I expected. Specifically, the addition of Optional made my code feel cleaner, more functional and less error prone. I had Java 8 on the brain when I tackled a small JavaScript project a few weeks ago. After finishing it, I decided to go back and try it again using Promises. I was immediately struck by how similar Promises are to a cousin of the Optional -- Result. In this post I'll describe Promises from the perspective of building robust pipelines for your code, later showing how Promises also unify synchronous and asynchronous code.

Quick Disclaimer

I will not discuss the second, optional onRejected argument to then. I don't like it and it can be completely replaced by using catch.

Type Signatures

I'll use ML-style type signatures in this post. If you've never seen them before, here's a quick example.

foo :: A -> B -> Promise C D  

This signature denotes a function named foo that takes two arguments, one of type A and one of type B and returns a promise with a success type of C and a failure type of D. JavaScript supports high-order functions (functions that take functions as arguments) and we can represent these with nested signatures.

foo :: (A -> B) -> C  

This signature represents a function that takes a function as an argument and returns a type of C. The passed function takes an argument of type A and returns a type of B.

In a nutshell, arguments are delimited by -> and token to the right of the last -> is the return type.

A list of type A is represented as [A]. If a function does not return a useful value, we use () to represent "no value".

Brief Introduction

A Promise, at its core, is a type that can have either a success value or a failure value. A Promise with a successful value of Int and a failure value of String would have the type Promise Int String.

We can create a successful Promise using Promise.resolve. The resolve function has the following type:

resolve :: A -> Promise A B  
> Promise.resolve(1)
Promise { 1 }  

We can create a failed promise with Promise.reject. It has the following type:

reject :: A -> Promise B A  
> Promise.reject("error")
Promise { <rejected> 'error' }  

Now suppose we have a function that can either succeed or fail. It should feel very natural to represent this return type with a Promise. Let's write a function that adds two numbers together, but fails if either number is negative. The signature of our function will be:

add :: Int -> Int -> Promise Int String  
let add = (a, b) => {  
  if (a < 0 || b < 0) {
    return Promise.reject("only positive numbers allowed");
  } else {
    return Promise.resolve(a + b);
  }
};

add(-1, 2);  
// => Promise { <rejected> 'only positive numbers allowed' }

add(1, 2);  
// => Promise { 3 }

That's it! You know promises!

Composition

Well, not really. Abstractions such as Promises are only useful if they are composable. Said differently, who cares about Promises if you can't get at the value inside them and do something with it? Suppose we'd like to use our add function above to add 1 to 2 and then add the result to 3. How do we get to the result of the first add and actually use it? There's a function for that, and it's called then. Here's the type of then:

then :: Promise A B -> (A -> C) -> Promise C B  

Phew, that's complicated. What's going on here? It's easier if we describe what then does in english. Given a promise, you call then with a function. If the promise is successful, the function will be called with the successful value and a new, successful promise will be created from the return value of that function. If the initial promise is unsuccessful, the function will not be called. Let's look at a few examples.

> let success = Promise.resolve(1).then(a => a + 1);
> success
Promise { 2 }  
> let error = Promise.reject("error").then(a => a + 1);
> error
Promise { <rejected> 'error' }  

We added 1 to 1 to create Promise { 2 } in the success case. In the failure case, the argument to then was ignored.

This seems good, but we want to call our add function. There is a problem here because our add function returns a Promise Int String and then doesn't expect its function to return a Promise. But wait! There's actually two ways to provide a function to then.

then :: Promise A B -> (A -> C) -> Promise C B  
then :: Promise A B -> (A -> Promise C B) -> Promise C B  

The function provided to then can either return a raw value or return a new Promise. If the value is raw, then knows to rewrap it in a Promise. If the return value is already a Promise, then will leave it alone. Now we can call our add function.

Promise.resolve(1)  
  .then(i => add(i, 2))
  .then(j => add(j, 3))
  .then(k => add(k, 4));

// Promise { 10 }

The fact that then gracefully handles failed promises is really important because it allows us to easily handle errors in dependent chains of function calls. Let's attempt to add a negative number in one of the intermediary calls to add.

Promise.resolve(1)        // Promise { 1 }  
  .then(i => add(i, 2))   // Promise { 3 }
  .then(j => add(j, -3))  // Promise { <rejected> 'only positive numbers allowed' }
  .then(k => add(k, 4));  // skipped, returning same as above

// Promise { <rejected> 'only positive numbers allowed' }

Handling Failure

The primary tool for handling failure is a counterpart to then called catch. Here's the type signature of catch:

catch :: Promise A B -> (B -> C) -> Promise C D  
catch :: Promise A B -> (B -> Promise C D) -> Promise C D  

Unlike then, catch only calls the provided function if the Promise has failed. If the provided function returns a raw value, catch wraps the result in a successful Promise. If the provided function returns a Promise, it is returned directly.

catch is primarily used to handle failure after one (or many) executions using then. Let's add a catch to the end of our add chain above. If anything in the chain fails, we'll return the value 0.

Promise.resolve(1)      // Promise { 1 }  
  .then(i => add(i, 2)) // Promise { 3 }
  .then(j => add(j, 3)) // Promise { 6 }
  .then(k => add(k, 4)) // Promise { 10 }
  .catch(e => 0);       // skipped, returning same as above

// Promise { 10 }
Promise.resolve(1)       // Promise { 1 }  
  .then(i => add(i, 2))  // Promise { 3 }
  .then(j => add(j, -3)) // Promise { <rejected> 'only positive numbers allowed' }
  .then(k => add(k, 4))  // skipped, returning same as above
  .catch(e => 0);        // Promise { 0 }

// Promise { 0 }

The catch and then functions have one more trick up their collective sleeves -- if the provided function throws an error, they will transform the error into a failed Promise.

> let thenResult = Promise.resolve(1).then(i => { throw "error"; });
> thenResult
Promise { <rejected> 'error' }  
> let catchResult = Promise.reject("foo").catch(e => { throw "error"; });
> catchResult
Promise { <rejected> 'error' }  

A Few More Tools

There are two other functions provided by Promises: all and race. Here are their types:

all :: [Promise A B] -> Promise [A] B  
race :: [Promise A B] -> Promise A B  

The all function takes a list of Promises. If all the Promises succeed, it returns a successful Promise whose value is a list of those successful values. Otherwise, it returns a failed promise whose value is the first failure.

> let success = Promise.all([Promise.resolve(1), Promise.resolve(2)]);
> success
Promise { [ 1, 2 ] }  
> let failure = Promise.all([Promise.resolve(1), Promise.reject("error")]);
> failure
Promise { <rejected> 'error' }  

The race function takes a list of Promises. The first promise that either succeeds or fails will be returned as the result of race.

> let success = Promise.race([Promise.resolve(1), Promise.reject("error")])
> success
Promise { 1 }  
> let failure = Promise.race([Promise.reject("error"), Promise.resolve(1)])
> failure
Promise { <rejected> 'error' }  

A Surprise Reveal

If Promises only provided the above behavior, they would already be worth it. They allow us to compose together dependent values that may succeed and may fail. This is very useful in real-world programs. However, Promises do one more very import thing: they provide the same API for synchronous and asynchronous code. The ability to create an asynchronous Promise is provided by the new function.

new :: ((A -> ()) -> (B -> ()) -> ()) -> Promise A B  

The new function takes a function with two arguments: a resolved and rejected callback that allow setting the value of the returned Promise at a later time. The Promise will be returned in a pending state, neither resolved nor rejected until one of the callbacks have been called.

Let's create a new function that fetches a number asynchronously and then uses our add function to add to it.

let fetchNumber = () => {  
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(1);
    }, 10);
  });
};

let add = (a, b) => {  
  if (a < 0 || b < 0) {
    return Promise.reject("only positive numbers allowed");
  } else {
    return Promise.resolve(a + b);
  }
};

let result = fetchNumber()  
  .then(i => add(i, 2))
  .then(j => add(j, 3))
  .then(k => add(k, 4))
  .catch(e => 1);

setTimeout(() => {  
  console.log(result);
}, 100);

// Promise { 10 }

Very convenient.

The addition of a Promise in the pending state changes the execution of the previously discussed functions. The then and catch functions will execute Promises sequentially, blocking on each Promise until it is either successful or failed.

The all and race functions will execute their Promises concurrently. In the case of all, it will block until a single Promise is failed or until all are successful. In the case of race, it will block until a single Promise moves from the pending state into either the success or failure state.

Wrap Up

I hope this helps you understand why Promises are useful and also makes it clearer how to use them. Now, more than ever before, it's important to understand Promises if you're working within the JavaScript ecosystem. Why? It turns out that async / await are just syntactic sugar on top of Promises.