Skip to content

Instantly share code, notes, and snippets.

@michaelwclark
Last active April 15, 2018 04:55
Show Gist options
  • Select an option

  • Save michaelwclark/ec3aac283bdd79660690310a79ca03ff to your computer and use it in GitHub Desktop.

Select an option

Save michaelwclark/ec3aac283bdd79660690310a79ca03ff to your computer and use it in GitHub Desktop.
Async/Await Destructuring

;TLDR: Don't write dependent promise chains; write destructured async/await using the following snippit.  GIST

const ad = async promise => {
  const [err, resp] = await promise.then(x => [{}, x]).catch(x => [x, {}])
  const [{ message }, { body }] = [err, resp]
  return [message, body]
}

const throwOrReturn = (err, val) => err ? throw err : return val

async function createUserA(){
   const [err, id] = await ad(mockAPICallPromise({ body:{id: 1 }}))
   return throwOrReturn(err, id)  
}

ES6 introduced the wonderful combination of async functions and the await expression into Javascript. Using these new features, we are able to write more neatly organized code that reads much like our synchronous code. I recently had a complex series of dependent promises that were originally written using the Promise syntax (then, catch, resolve, reject). The code worked fine but left a lot to be desired. I decided to write async/await code with a small helper utility to help the readability and maintainability of the code. Writing our Promises with either callbacks or promise syntax is fine for one or two chained async operations. Our code will quickly become unwieldy if we try to do many more than that.

Let's consider this scenario I slightly adapted from a real-world scenario. We have a web application that needs to interact with two APIs during a user session. The logic flows as follows:

  1. Create User Object on API A
    • on Error, report to User
  2. Create User Object on API B
    • on Error, report to User and Delete User Object on API A
  3. Update User Object on API A with the ID from API B
    • on Error, report to User and Delete User Object on API A & B
  4. Return the object from API A (returned from step 3)

Let's also say we know that our APIs both return an error object with a message property and a response object with a body property.

Although there are only a few steps, depending on where the errors happen, this can get unwieldy. Let's look at a possible way to handle this using the Promise syntax.

GIST

// Our example function to create and populate a user on 2 apis.
function createAndPopulateUserObject (successCallback, errorCallback) {
  const A_createUser = mockAPICallPromise({ body: { id: 1 } })

  // Functional programing no no, shouldn't need to update outer scope,
  // but not a lot of options here.
  let A_userID
  let B_userID

  // Create user on API A
  A_createUser.then(resp => {
    A_userID = resp.body.id
    // Create user on API B
    return new Promise((resolve, reject) => {
      mockAPICallPromise({ body: { B_userID: '000001' } })
        .then(resolve) // success, move on
        .catch(error => {
          // error creating on API B
          deleteUserA(A_userID)
          reject(error)
        })
    })
  })
    .then(resp => {
      // Update API A with ID from API B
      B_userID = resp.body.B_userID
      return new Promise((resolve, reject) => {
        mockAPICallPromise({ body: { A_userID, B_userID } })
          .then(resolve) // success, move on
          .catch(error => {
            // error updating API A
            deleteUserA(A_userID)
            deleteUserB(B_userID)
            reject(error)
          })
      })
    })
    .then(resp => {
      successCallback({ A_userID, B_userID })
    })
    .catch(error => errorCallback(error))
}

createAndPopulateUserObject(console.log, console.error)

As you can see, this code is difficult to reason with. It wasn't fun to write, and making changes to it in the future is going to be a bear! This code also doesn't take into account other production requirements like logging, or authentication mechanisms needed that may be different between API A and API B.

Using aysnc/await, we are able to 'flatten' out this code quite a bit and make it much easier to read and understand.

GIST

// Simply returns what you pass in as promise form. Will reject if 2nd param
// isn't null. Will resolve valued passed in otherwise. This is only for demonstration.
const mockAPICallPromise = (retVal, error = null) =>
  new Promise((resolve, reject) => (error && reject(error)) || resolve(retVal))


async function createAndPopulateUserObject (successCallback, errorCallback) {
  // Create user on API A on error callback error.
  let errorFound = false
  let A_createUserResp = await mockAPICallPromise({ body:{id: 1 }}).catch(err=> {
    errorCallback(err);
    errorFound = true;
  })
  if(errorFound) return null

  const A_userID  = A_createUserResp.body.id
  
  // Create user on API B
  const B_createUserResp = await mockAPICallPromise({ body:{id: '000001' }})
    .catch(err=>{
      errorCallback(err); 
      deleteUserA(A_userID)
      errorFound = true;
    })
  if(errorFound) return null
  
  const B_userID = B_createUserResp.body.id
  const A_updateUserResp = await mockAPICallPromise({body:{ A_userID, B_userID }})
    .catch(err=>{
      errorCallback(err); 
      deleteUserA(A_userID)
      deleteUserB(B_userID)
      errorFound = true;
    })
  if(errorFound) return null

  successCallback({ A_userID, B_userID })
}

createAndPopulateUserObject(console.log, console.error)

This code is much better and, with a little bit of effort, could easily be refactored into a few different functions to make it even more readable. However, I wasn't quite satisfied with doing my errors like this and wanted to clean it up even further.

Since we know that errors and responses all follow the same pattern, we can utilize array and object destructuring with a small helper utility to clean up this code even further.

GIST

// Simply returns what you pass in as promise form. Will reject if 2nd param
// isn't null. Will resolve valued passed in otherwise. This is only for demonstration.
const mockAPICallPromise = (retVal, error = null) =>
  new Promise((resolve, reject) => (error && reject(error)) || resolve(retVal))


async function createAndPopulateUserObject (successCallback, errorCallback) {
  // Create user on API A on error callback error.
  const [A_createUserErr, A_createUserResp] = await ad(mockAPICallPromise({ body:{id: 1 }}))
  if(A_createUserErr) {
    errorCallback(A_createUserErr) 
    return null
  }

  const A_userID  = A_createUserResp.id
  
  // Create user on API B
  const [B_createUserErr, B_createUserResp] = await ad(mockAPICallPromise({ body:{id: '000001' }}))
  
  if(B_createUserErr){
    errorCallback(err)
    deleteUserA(A_userID)
    return null
  }
    
  
  const B_userID = B_createUserResp.id

  // Update user on API A
  const [A_updateUserErr, A_updateUserResp] = await mockAPICallPromise({body:{ A_userID, B_userID }})

  if(A_updateUserErr){
    errorCallback(err); 
    deleteUserA(A_userID)
    deleteUserB(B_userID)
    return null
  }
    
  successCallback({ A_userID, B_userID })
}

createAndPopulateUserObject(console.log, console.error)

This is getting much easier to read and reason with. It's not that much different than the first async/await example, but we are beginning to adhere to better design principles. Mainly, internal functions aren't modifying outer scope variables, but rather are returning directly in our error checks.

Let's take one more pass over this for a final version that will feel much nicer in our repositories.

GIST

// This helper method will allow us to capture succes and error
const ad = async promise => {
  const [err, resp] = await promise.then(x => [{}, x]).catch(x => [x, {}])
  const [{ message }, { body }] = [err, resp]
  return [message, body]
}


// Simply returns what you pass in as promise form. Will reject if 2nd param
// isn't null. Will resolve valued passed in otherwise. This is only for demonstration.
const mockAPICallPromise = (retVal, error = null) =>
  new Promise((resolve, reject) => (error && reject(error)) || resolve(retVal))

const throwOrReturn = (err, val) => err ? throw err : return val

async function createUserA(){
   const [err, body] = await ad(mockAPICallPromise({ body:{id: 1 }}))
   return throwOrReturn(err, body.id)  
}

async function createUserB(){
  const [err, body] = await ad(mockAPICallPromise({ body:{id: '000001' }}))
  return throwOrReturn(err, body.id)
}

async function updateUserA(A_userID, B_userID){
  const [err, body] = await mockAPICallPromise({body:{ A_userID, B_userID }})
  return throwOrReturn(err, body.id) 
}

async function createAndPopulateUserObject (successCallback, errorCallback) {
  let A_userID, B_userID
  try{
    A_userID  = await createUserA()
    B_userID = await createUserB()
    await updateUserA(A_userID, B_userID) //don't care about return, only that it doesn't throw
    successCallback({ A_userID, B_userID })
  }catch (e){
    errorCallback(e)
    A_userID && deleteUserA(A_userID)
    B_userID && deleteUserB(B_userID)
  }
}

createAndPopulateUserObject(console.log, console.error)

We are still using outer scope of try for the ids, but overall code readability and testability is MUCH better. Our code reads easier, and we are able to see in one spot what happens on success and what happens on errors. We could add even more abstraction to the mix to write less code, but I feel this has a decent balance between abstraction and readability. After a few layers of abstraction, we get back into issues down the road if and when something needs to change. 

The downside of this particular async code destructure snippit is that it's assuming your API is returning the errors and successes in the same format. This is rarely the case, but this series of examples should act as a guide to show you how to utilize ES6 features to clean up your code considerably even if you can't abstract every pattern into a small helper method. 

I should also note that in my production implementation of this, I don't use callbacks, but rather return promises to work with further up the chain. However, that's a topic for another discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment