How (Not) To Thread State Through a JavaScript Promise Chain

6 mins

Last weekend I created a GitHub Action and submitted it to the GitHub Hackathon. It was a relatively simple workflow that brings up a WireMock API for tests to run against.

It was pretty cool as I hadn’t had a chance to write a Node application before. (The other way to write an Action is to build a Docker image, in which case any language can be used.)

In this post, I’m going to share my thought process bemoan my follies as I went about writing the Action.

Action repository: https://github.com/williamhaw/setup-wiremock-action

Tl;dr In JavaScript, use async await to deal with intermediate state, much simpler. Also, just having documentation from types make TypeScript awesome, even if you don’t write your application in TypeScript.

Pseudocode

  1. Download the WireMock jar.
  2. Copy stubs (request and response definitions) to folders read by WireMock when it starts.
  3. Start WireMock in the background.
  4. Ping WireMock (to make sure it has started).
  5. Run test command.
  6. Cleanup
    1. Kill WireMock
    2. Get the WireMock logs (these contain request mismatches, very important when debugging).
    3. Delete copied stubs.
    4. Tell Github if this run failed.

I started out stubbing all the functions and deciding on their inputs and output. I noticed:

What all these have in common is that these steps take state that is not from the immediately preceding step.

At this point I could either store the required values in global state or find an abstraction that allows me to be able to retrieve state that needs to span across multiple steps.

I read up on Javascript Promises and realized that I could use a chain of Promises to enforce the execution order without maintaining global state. Just one problem:

//promises are chained like this
promiseReturningFunctionOne() //say this returns an object
  .then(promiseReturningFunctionTwo) //this function takes one input and returns a Promise
  .then(state => promiseReturningFunctionThree(state)) // this is more explicit passing of state
  .then({myValue} => promiseReturningFunctionThree(myValue)) //to extract one value out of the preceeding state
  .catch(e => {console.error(e)}) // handle all errors, processing stops at the first function that throws an error

The state that each function gets is from the one immediately preceding it; that state object is not the same as the one wrapping myValue.

After thinking about it, I came up with something like this:

downloadWireMock()
  .then(state => {
    copyStubs(); //perform side effect, doesn't return any value
    return state;
  })
  .then(state => {
    const wiremockProcessState = startWireMock(state.wireMockPath);
    //merge state so that later functions have access
    return {
      ...state,
      ...wiremockProcessState
    }
  })
  .then(state => {console.log(`State after WireMock started: ${state}`)})
  ...

The Good

The Bad

The Ugly

I wasn’t really happy with this solution, but I thought it had its merits. Then I started wondering came to my senses and tried implementing it with async/await:

(async function() {
  try{
    const wiremockPath = await downloadWireMock();
    copyStubs();
    var wiremockProcess = startWireMock(wireMockPath); //need hoisting to be referenced in the finally clause
    ...
  } catch (e) {
    console.error(e);
  } finally {
    killWireMock(wiremockProcess);
  }
})()

and it was much simpler. I realised that maybe the abstraction I needed all along were statements. Simple statements also have many of the good properties above and don’t have the disadvantages of my approach 🤦‍♂️. In particular:

  1. You can’t use a value before it has been created.
  2. Variables are scoped to the function they are declared in.
  3. Can literally be read top to bottom.

To be clear, this is not a knock against promise chains. They are useful if the later parts of the chain only depend on the step immediately before. In my case I was able to reap the benefits of async/await because I benefited from the illusion that all my asynchronous functions were synchronous.

Notes