Categories


Archives


Recent Posts


Categories


Promises: A Better Asynchronous Grammar

astorm

Frustrated by Magento? Then you’ll love Commerce Bug, the must have debugging extension for anyone using Magento. Whether you’re just starting out or you’re a seasoned pro, Commerce Bug will save you and your team hours everyday. Grab a copy and start working with Magento instead of against it.

Updated for Magento 2! No Frills Magento Layout is the only Magento front end book you'll ever need. Get your copy today!

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

  1. Return a Value
  2. 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

  1. Scheduling the async work to be done
  2. “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.

Series Navigation<< The Challenges of Asynchronous GrammarThe Practical Problems of Javascript Promises >>

Copyright © Alana Storm 1975 – 2023 All Rights Reserved

Originally Posted: 24th July 2019

email hidden; JavaScript is required