One of my favorite things about Promises in JavaScript is that they have a well-defined and narrow purpose: you ask for data and get a Promise that will give it to you asynchronously.

Promises are not a solution for handling events. Because a Promise only resolves once, it's not a good fit to tell you each time a button is clicked or an HTTP request comes in over the network.

However, there is a bit of useful information you can get from a Promise other than the data that will come through it: sometimes you want to know when an asynchronous operation is complete. Here's an example of what I mean:

prompt is a node library that accepts text input in the terminal. It exposes a traditional node-callback-style API. I recently had occasion to promisify it and ran into a funny problem. This is what I initially wrote:

const prompt = require('prompt')

const promisePrompt = (name) => {
  return new Promise((resolve, reject) => {
    prompt.get(name, (err, result) => {
      if (err) {
        reject(err)
      } else {
        resolve(result[name])
      }
    })
  })
}

const fields = ['first', 'middle', 'last']

Promise
  .all(fields.map(promisePrompt))
  .then(console.log.bind(console))
  .catch(console.error.bind(console))

(If you're a bit fuzzy on how promises work, I recorded a conference talk on Javascript promises that you might want to watch.)

Don't worry if you don't see anything wrong here. The problem isn't in the code exactly, I just made an incorrect assumption about how prompt.get() works.

At the bottom of the code example, we're mapping across our field names and calling promisePrompt three times in a row. Then we gather those three promises back together with Promise.all(…).

Unfortunately, if you call prompt.get() a second time before the user is done typing into your first invocation, prompt will just use the same input for both. In other words, prompt is not written to be run more than once at the same time.

$ node index.js
prompt: last:  LLLiiinnnddd??????
[ 'Lind??', 'Lind??', 'Lind??' ]
$

When we map across our fields array, we're calling prompt.get() three times before the user can even type a character.

Please note, this is not how prompt is supposed to be used - it wants you to pass in a list of fields instead of calling it once per field. So this example is a bit contrived, but it's a great chance to use promises in a novel way.

What can we do to fix this?

Here is a function that takes a promise-returning function (f) and wraps it so it will wait until any previous call is finished:

const lockify = (f) => {
  var lock = Promise.resolve()
  return (...params) => {
    const result = lock.then(() => f(...params))
    lock = result.catch(() => {})
    return result
  }
}

When you call lockify:

  • It immediately creates an internal lock promise.
  • It returns a new function.

The returned function also does two things when you invoke it:

  • It creates a result promise that will get returned.
  • It overwrites lock with a new promise that waits on result.

Eventually, after the initial lock finishes, f will be called. Whenever the promise that f returns is done, result and the new lock will both resolve.

As a bonus, lockify is an existing npm package.

Why does this work?

lock is the heart of lockify and is being used in an unusual way for a promise: we never care what its value is. Instead, it's being used exclusively for timing.

Notice that lock is the only identifier defined as a var. That's so we can overwrite lock each time our function is called.

Also notice that we create each lock except the first by calling result.catch(() => {}). This means that lock will resolve after result, even if result was rejected.

Here is the full code for our fixed version:

const prompt = require('prompt')

const promisePrompt = (name) => {
  return new Promise((resolve, reject) => {
    prompt.get(name, (err, result) => {
      if (err) {
        reject(err)
      } else {
        resolve(result[name])
      }
    })
  })
}

const lockify = (f) => {
  var lock = Promise.resolve()
  return (...params) => {
    const result = lock.then(() => f(...params))
    lock = result.catch(() => {})
    return result
  }
}
const safePrompt = lockify(promisePrompt)

const fields = ['first', 'middle', 'last']

Promise
  .all(fields.map(safePrompt))
  .then(console.log.bind(console))
  .catch(console.error.bind(console))

$ node index.js
prompt: first:  Neal
prompt: middle:  Danger
prompt: last:  Lindsay
[ 'Neal', 'Danger', 'Lindsay' ]
$

What does this mean?

It's very rare that you have to worry about resource contention this way. Most of the asynchronous calls you make are also perfectly happy to run concurrently (like HTTP requests and file reads).

So then what can we take away from this exercise? First, promises are abstract things that can benefit from being thought about in a very "math-y" way, similar to how you think about other data types. You might have date utility functions that tell you when next Tuesday is, or whether a date is on a leap year. Your string utility library might have functions to capitalize words or normalize whitespace. Promises can also be manipulated in standard ways because of how we know they work, regardless of their enclosed values. Our lockify function doesn't care what f() does, other than that it is a function that could return a promise.

Second, when you're writing a function to explicitly manipulate promises, it's important to keep in mind what happens immediately and what happens later. lock gets immediately set when we call lockify, and gets overwritten immediately on each call of the returned function. f, on the other hand, gets called after the previous lock resolves.

Third, at all other times it is important to forget about all that temporal stuff. The beauty of promises is that most of your code doesn't worry about when it will run. It just says "when we have the data, do this stuff". I love mapping across some data with a promise-returning function and then throwing the result into Promise.all. When does that stuff get done? Most of the time we don't need to think about it.

Test Double helps software
teams scale with stability.