- The Challenges of Asynchronous Grammar
- Promises: A Better Asynchronous Grammar
- The Practical Problems of Javascript Promises
- Promise State
- What is Async/Await Good For?
Last time we discussed some of the challenges with continuation passing style asynchronous APIs in javascript. Our main takeaway? Relying on library authors to conform to a particular style of continuation passing leads to fragmentation, which in turns leads to software systems that are harder to onboard new programmers into.
Over the years, the consensus in the various javascript communities has been that “promise” based APIs are the solution to the problems presented by continuation passing style APIs. Today we’re going to take a look at how promises offer a different way of working with asynchronous code.
All of our code examples today were written with NodeJS, version 10. Javascript’s a moving target, and if you’re using a different environment there may be some specifics that behave differently, but the fundamentals of what we’re describing should still apply.
What is a Promise?
You’ll ofter hear promises described something like a proxy for a future unknown value. While that’s true, I didn’t find this definition useful when I was initially learning promises. Instead, I prefer to think of promises as javascript objects that will run a piece of asynchronous work, and that this asynchronous work will either
- Return a Value
- Raise an Error/Exception
For our first example, we’ll focus on the “returning a value” use-case.
Let’s say we’re the system developer, and we want to provide client programmers with a promise that returns the message Hello World asynchronously. We might write a function that looks like this
// File: promise-example.js
const createHelloWorldPromise = function() {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
resolve("Hello World")
}, 0);
})
return promise
}
This code defines a function named createHelloWorldPromise
that returns an object whose type is Promise
. To create this promise object, we use the built-in Promise
constructor-function/class. The constructor function accepts a single function, sometimes called a callback, as an argument. It’s our responsibility to define this callback function. This callback function is responsible for
- Scheduling the async work to be done
- “Returning” a value for the async work by calling the
resolve
-callback
This is a similar pattern to our continuation-style passing programming in that we never “return” a value — instead we use a callback function to communicate the result of our asynchronous work.
Getting a Promised Value
So, we have “systems code” (in the loosest sense of the word) that will asynchronously generate the text Hello World
— but how can someone use that value? Or, in my terminology, how can the client programmer ask the promise for its value?
This is where the promise’s then
function enters the picture. When you have a promise object, you’ll call the promise’s then
method, and pass it another callback-function. This callback function will receive a single argument, and that argument is the final resolved value of this promise’s asynchronous work.
This might make more sense in code. Consider this small program
// File: promise-example.js
const createHelloWorldPromise = function() {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
resolve("Hello World")
}, 0);
})
return promise
}
let promise = createHelloWorldPromise()
promise.then(function(returnedValue) {
console.log(returnedValue)
});
Here we’re using the createHelloWorldPromise
function we wrote earlier to create a promise object. Then, we’re calling that promise’s then
method, and passing then
a function that looks like this
function(returnedValue) {
console.log(returnedValue)
}
The argument to this function, returnedValue
, will contain the string “Hello World”
$ node promise-example.js
Hello World
We tell the promise we want its value by passing then
a function, and the promise calls our function with the value. At their simplest, this is all promises are. A way to access the result of some asynchronous work.
Promises vs. Continuation Passing Async APIs
In a non-promised based asynchronous API, the system developer asks the client developer for a function definition. The client developer writes this function and hands it to the system. The system developer will call this function when the async work is done, an error has happened, or whenever else they want. Without promises, it’s each library author deciding how continuation passing works.
In a promise based API, the system developer tells a promise about some async work they want done. The client developer still needs to write a function in order to receive the results of the asynchronous work, but they’re asking the promise for that work and writing their callback in a way the promise understands.
When I look at the design of promises, that seems like their main value. They improve on traditional async patterns by providing systems and client developers with a shared system for doing async work. When a client programmer knows a package uses standard promise objects, then know how to get at the async work (the then
method), and they know the format of their callback signature. There’s less cognitive overhead in keeping track of each library’s callback style. All the client developer needs to do is write a then
handler and they’re all set.
If, like me, you’re used to thinking of things in terms of “systems programming” and “client programming”, promises stretch those metaphors a bit. That’s because there’s a third party involved — the developers who created the promise system. So our traditional systems developer (the one creating the promise object) is also a client of the promise system. I suspect this may be why promises are a little hard to fully-understand at first.
Error Handling in Promises
Earlier we described promises as
javascript objects that contain instructions for performing a piece of asynchronous work that will either return a value, or raise an error/exception
Let’s take a look at how promises deal with an error or exception.
As the systems developer (the developer writing code that makes promises for people), you use the resolve
function to tell the promise the value returned by your asynchronous work.
// File: promise-example.js
const createHelloWorldPromise = function() {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
resolve("Hello World")
}, 0);
})
return promise
}
/* ... */
However, that Promise
constructor callback has a second argument — the reject
-callback. If your asynchronous work encounters an error, instead of calling resolve
, you call the reject
callback and pass it your error.
A simple, silly example might look like this
// File: promise-example.js
const createPassOrFailPromise = function(isPassed) {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
if(!isPassed) {
reject(new Error("Hello Error"))
} else {
resolve("Hello World")
}
}, 0);
})
return promise
}
The createPassOrFailPromise
function can create two different promises — one that always succeeds (resolve
), another that always fails (reject
).
As a client developer, when you’re using the instantiated promise, you need to be ready to handle the result of the promise being a rejection. There’s two ways to handle this error condition.
The first is to use a second callback with the then
method. That would look something like this.
// File: promise-example.js
const createPassOrFailPromise = function(isPassed) {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
if(!isPassed) {
reject(new Error("Hello Error"))
} else {
resolve("Hello World")
}
}, 0);
})
return promise
}
let handleSuccess = function(result) {
console.log("It worked!")
console.log(result)
}
let handleError = function(error) {
console.log(error)
console.log("It DID NOT work")
}
let promise = createPassOrFailPromise(true)
promise.then(handleSuccess, handleError);
The above program creates a promise by calling the createPassOrFailPromise
function. Then, it asks the promise for its value by calling the then
method. Rather than write the callbacks out inline, we’re defining them beforehand (handleSuccess
, handleError
).
The first argument to then
is the handleSuccess
callback. Running the above program will result in our promised value being printed out to the screen.
$ node promise-example.js
It worked!
Hello World
So far, three’s nothing new here. However, if we change this program to give us a promise that always fails
// File: promise-example.js
/* ... */
let promise = createPassOrFailPromise(false)
promise.then(handleSuccess, handleError);
/* ... */
Then our output will look like this
$ node promise-example.js
Error: Hello Error
at Timeout._onTimeout (/path/to/promise-example.js:5:24)
at ontimeout (timers.js:436:11)
at tryOnTimeout (timers.js:300:5)
at listOnTimeout (timers.js:263:5)
at Timer.processTimers (timers.js:223:10)
It DID NOT work
That’s because the promise used our second callback, the handleError
function.
// File: promise-example.js
/* ... */
let handleError = function(error) {
console.log(error)
console.log("It DID NOT work")
}
/* ... */
The then
method accepts two arguments.
// File: promise-example.js
/* ... */
promise.then(handleSuccess, handleError);
/* ... */
The first is a callback for handling the results of a successful asynchronous operation. The second is a callback for handling a failed asynchronous operation.
The promise system forces the system developer creating the promise to indicate errors in a specific way, and the client developer using the promise can expect to receive errors in a specific way. Once again, promises serve as the gateway between the async world and the real one.
Alternate Error Handling Syntax
When it comes to handling the error path of a promise, there’s a second option for client programmers: The promise’s catch
method
// File: promise-example.js
/* ... */
let promise = createPassOrFailPromise(false)
promise.then(handleSuccess).catch(handleError);
/* ... */
Methods on promises are designed to chain together. If you call catch
immediately after calling then
, you’re telling the promise system that the function passed to catch
should handle the error case. This is functionally equivalent to
// File: promise-example.js
/* ... */
promise.then(handleSuccess, handleError)
/* ... */
Also? Although promise objects don’t require you to handle errors, you should always handle the errors. In modern versions of node, if the async work is rejected and you don’t handle it,
// File: promise-example.js
/* ... */
let promise = createPassOrFailPromise(false)
promise.then(handleSuccess);
/* ... */
node will barf up an error and stop running your program.
$ node promise-example.js
(node:24939) UnhandledPromiseRejectionWarning: Error: Hello Error
at Timeout._onTimeout (/path/to/promise-example.js:5:24)
at ontimeout (timers.js:436:11)
at tryOnTimeout (timers.js:300:5)
at listOnTimeout (timers.js:263:5)
at Timer.processTimers (timers.js:223:10)
(node:24939) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:24939) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
As to why there’s two methods for providing an error handling callback — I’m not deep enough in the node community to say for sure. I do know that one nice side effect of having catch
around is if you’re working with a promise object created by a call to then
, you can add the error handler later on.
// File: promise-example.js
/* ... */
let promise = createPassOrFailPromise(false)
promise = promise.then(handleSuccess);
// ... oceansfull of things happen ...
promise.catch(handleError);
Promises as Callback Sequencing
The final thing we’ll talk about today is chaining together multiple promises. Let’s take a look at one more sample program.
// File: promise-example.js
const createPassOrFailPromise = function(message) {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
if(!message) {
reject(new Error("Hello Error"))
} else {
resolve(message)
}
}, 0);
})
return promise
}
let handleSuccess = function(result) {
console.log(result)
}
let handleError = function(error) {
console.log("It DID NOT work")
console.log(error)
}
let promise = createPassOrFailPromise("Hello World")
promise.then(handleSuccess, handleError);
Another small program — this time we’ve built a createPassOrFailPromise
function that accepts a string, and then will asynchronously output that string. We have the usual promise client code as well, which means when we run the program, we’ll see some output.
$ node promise-example.js
Hello World
This is a great approach if we have one piece of asynchronous work. However, what happens if we want to perform another piece of asynchronous work after the first? At first blush, it might seem like we’re back in “callback-heck”.
// File: promise-example.js
/* ... */
let handleSuccess = function(result) {
const innerPromise = createPassOrFailPromise("Goodbye Callbacks")
innerPromise.then(function(result) {
const innerInnerPromise = createPassOrFailPromise("Oh no help")
innerInnerPromise.then(function(result) {
console.log("Inner Inner Result")
})
console.log("Inner Result")
}, handleError)
console.log(result)
}
/* ... */
You could write code like this if you wanted to. However, promises provide a mechanism for flattening out your callback chains. If a success handling function returns another promise, you can access this promise’s value by chaining another then
call off the returned promise. i.e., something like this
// File: promise-example.js
const createPassOrFailPromise = function(message) {
let promise = new Promise(function ourAsyncWork(resolve, reject){
setTimeout(function() {
if(!message) {
reject(new Error("Hello Error"))
} else {
resolve(message)
}
}, 0);
})
return promise
}
let handleError = function(error) {
console.log("It DID NOT work")
console.log(error)
}
let handleSuccess = function(result) {
console.log(result)
const promise = createPassOrFailPromise("Hello Second Promise")
return promise
}
let handleSuccess2 = function(result) {
console.log("The Second Promise")
console.log(result)
}
let promise = createPassOrFailPromise("Hello World")
promise.then(handleSuccess, handleError)
.then(handleSuccess2, handleError);
console.log("Main Program Done")
Here we’ve chained another then
call off our first.
// File: promise-example.js
/* ... */
promise.then(handleSuccess, handleError)
.then(handleSuccess2, handleError);
/* ... */
We’ve also changed the handleSuccess
function such that it returns a promise.
// File: promise-example.js
/* ... */
let handleSuccess = function(result) {
console.log(result)
const promise = createPassOrFailPromise("Hello Second Promise")
return promise
}
/* ... */
When a success handler returns a promise, that promise will be passed to the second then
function. Without that second then
function, the returned promise will never perform its asynchronous work.
You can also use the catch
method to chain replies — each catch
following the then
it should handle errors for.
// File: promise-example.js
/* ... */
promise.then(handleSuccess).catch(handleError)
.then(handleSuccess2).catch(handleError);
/* ... */
While promises don’t eliminate callbacks all together, they do “flatten” things out. Consider the above example with inline success handlers
promise.then(function(){
// first bit of async work
}).catch(handleError).then(function(){
// second bit of async work
}).catch(handleError);
That’s only one level of callback-heck, which is manageable.
Wrap Up
We’ll leave it there for today. Promises do offer a simpler way of reasoning about your programs and writing asynchronous javascript. However, they’re not a panacea. In our next article we’ll discuss some of the practical downsides of promise based APIs.