Comparing Callbacks, Promises and Async Await in TypeScript

How do callbacks, promises and async/await compare to each other? This article shows the same scenario using each of these three techniques so you can see the differences and choose which appeals most to you.

The code uses TypeScript, but can easily be adapted to JavaScript.

Learn more about this code in my course Creating Asynchronous TypeScript Code on Pluralsight.

This article shows three different techniques to get an object graph of a hero with that hero's orders and account rep.

The scenario for these examples are that there is a set of heroes. Each hero has to shop, so they make orders. And each hero has a dedicated account rep for their orders. Heroes are like customers, if that helps 😄

Callbacks

When writing callbacks we end up with a series of nested calls. This is easy to see when we look a the code below as it all tends to drift to the right. This drifting is also known as the "Pyramid of Doom".

The code below gets a hero by the hero's email. Then it gets the orders for the hero and merges them into the hero object. Then it gets the account repo for the hero and merges that data into the hero object. Finally, it returns the hero with all of the orders and account rep data.

const getHeroTreeCallback = function(
  email: string,
  callback: Callback,
  callbackError?: CallbackError,
) {
  getHeroCallback(
    email,
    hero => {
      getOrdersCallback(
        hero.id,
        orders => {
          hero.orders = orders;
          getAccountRepCallback(
            hero.id,
            accountRep => {
              hero.accountRep = accountRep;
              callback(hero);
            },
            error => callbackError(error),
          );
        },
        error => callbackError(error),
      );
    },
    error => callbackError(error),
  );
};

Individual Callbacks

The getHeroTeeCallback function calls nested functions. You can see these in the following code example.

There are three functions here. Each gets the Hero, the Hero's orders, and the Hero's account reps, respectively. Notice that each follows a pattern of using axios to get the data over http, and invokes the callback or callbackError function based on whether the code worked or encountered an error.

const getHeroCallback = function(
  email: string,
  callback: Callback,
  callbackError?: CallbackError,
) {
  axios
    .get(`${apiUrl}/heroes?email=${email}`)
    .then((response: AxiosResponse) => {
      const data = parseList(response);
      const hero = data[0];
      callback(hero);
    })
    .catch((error: AxiosError) => {
      console.error(`Developer Error: Async Data Error: ${error.message}`);
      callbackError(`Oh no! We're unable to fetch the Hero`);
    });
};

const getOrdersCallback = function(
  heroId: number,
  callback: Callback,
  callbackError?: CallbackError,
) {
  const url = heroId ? `${apiUrl}/orders/${heroId}` : `${apiUrl}/orders`;
  axios
    .get(url)
    .then((response: AxiosResponse) => {
      const orders = parseList(response);
      callback(orders);
    })
    .catch((error: AxiosError) => {
      console.error(`Developer Error: Async Data Error: ${error.message}`);
      callbackError(`Oh no! We're unable to fetch the Orders`);
    });
};

const getAccountRepCallback = function(
  heroId: number,
  callback: Callback,
  callbackError?: CallbackError,
) {
  const url = `${apiUrl}/accountreps/${heroId}`;
  axios
    .get(url)
    .then((response: AxiosResponse) => {
      const list = parseList(response);
      const accountRep = list[0];
      callback(accountRep);
    })
    .catch((error: AxiosError) => {
      console.error(`Developer Error: Async Data Error: ${error.message}`);
      callbackError(`Oh no! We're unable to fetch the Account Rep`);
    });
};

Promises

Promises do have some indentation to the right, like callbacks. However it tends to not be as extreme. The promise is called to get the Hero and then the orders and the account reps are retrieve at the same time using Promise.all.

This is different than the allback technique where each call is made one at a time. Promise.all allows you to take the hero data and use it to make two calls: one for orders and one for account reps. When both have returned their responses, the code moves in to the next then.

The final step is to merge the orders and account repo data into the Hero.

Notice also, that the nested functions are inside of the getHeroTreeProimise function. This allows the those functions to access the hero variable in the outer function. Otherwise, you'd want to pass the hero around. I prefer this type of closure technique, as it gives those functions context of where they should work (on a hero).

const getHeroTreePromise = function(searchEmail: string) {
  let hero: Hero;

  // Level 1 - Get the hero record
  return (
    getHeroPromise(searchEmail)
      // Level 2 - Set the hero, and pass it on
      .then((h: Hero) => {
        hero = h;
        return h;
      })
      // Level 3 - Get the orders and account reps
      .then((hero: Hero) => Promise.all([getOrders(hero), getAccountRep(hero)]))
      // Extract the orders and account reps and put them on their respective Hero objects
      .then((result: [Order[], AccountRepresentative]) => mergeData(result))
  );

  function getOrders(h: Hero): Promise {
    hero = h;
    return h ? getOrdersPromise(h.id) : undefined;
  }

  function getAccountRep(h: Hero): Promise {
    hero = h;
    return h ? getAccountRepPromise(h.id) : undefined;
  }

  function mergeData(result: [Order[], AccountRepresentative]): Hero {
    const [orders, accountRep] = result;
    if (orders) {
      hero.orders = orders;
    }
    if (accountRep) {
      hero.accountRep = accountRep;
    }
    return hero;
  }
};

Async Await

The async await technique gets the same data, but follows a much more "do this then do that" flow. The code flows line by line, just like syncrhonous code flows.

First you get the hero. Then you get the orders and account rep. Notice that you can use the Promise.all combined with the async await. This is really helpful as it allows you to make boths calls at the same time, but still "await" their response. Then those responses are merged into the hero object.

This code feels the cleanest to me. Less lines and arguably easier to read.

const getHeroTreeAsync = async function(email: string) {
  const hero = await getHeroAsync(email);
  if (!hero) return;

  const [orders, accountRep] = await Promise.all([
    getOrdersAsync(hero.id),
    getAccountRepAsync(hero.id),
  ]);
  hero.orders = orders;
  hero.accountRep = accountRep;
  return hero;
};

Nested Async Functions

The functions that the async await function getHeroTreeAsync calls are shown below. Here they use axios with the async and await keywords. The data is retrieved adn then returned.

const getHeroAsync = async function(email: string) {
  try {
    const response = await axios.get(`${apiUrl}/heroes?email=${email}`);
    const data = parseList(response);
    const hero = data[0];
    return hero;
  } catch (error) {
    handleAxiosErrors(error, 'Hero');
  }
};

const getOrdersAsync = async function(heroId: number) {
  try {
    const response = await axios.get(`${apiUrl}/orders/${heroId}`);
    const data = parseList(response);
    return data;
  } catch (error) {
    handleAxiosErrors(error, 'Orders');
  }
};

const getAccountRepAsync = async function(heroId: number) {
  try {
    const response = await axios.get(`${apiUrl}/accountreps/${heroId}`);
    const data = parseList(response);
    return data[0];
  } catch (error) {
    handleAxiosErrors(error, 'Account Rep');
  }
};

Resources

You can learn more about these techniques fro these resources: