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> '...' }
.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> '...' }
.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.