DEV Community

Ivan Alejandro
Ivan Alejandro

Posted on • Originally published at engineering.spideroak.com

Unraveling callbacks with async functions

Requirements

I assume you are familiar with Javascript and these concepts:

Example and problems

This is a real life example of how a function that moves a file looked. This was part of one of our mobile apps.

The code is not really complex, but it was hard to read at a glance; it felt bad.
So I tried to refactor it a little to see if I could improve its readability.

import path from 'path';

/**
 * Moves a file from one directory to another.
 *
 * @param { String } basePath: the base path for both relativeSourcePath
 *                             and relativeDestinationPath.
 * @param { String } relativeSourcePath: the relative path of the file.
 * @param { String } relativeDestinationPath: the relative new path of the file.
 *
 * @return { Promise } resolves with no value if the file is
 *                     successfully moved.
 */
function move(basePath, relativeSourcePath, relativeDestinationPath) {
  return new Promise((resolve, reject) => {
    const destinationPath = path.dirname(relativeDestinationPath);
    const filename = path.basename(relativeDestinationPath);

    ensureDirectory(basePath, destinationPath).then(() => {
      window.resolveLocalFileSystemURL(basePath, baseDirEntry => {
        baseDirEntry.getFile(relativeSourcePath, {}, sourceFileEntry => {
          baseDirEntry.getDirectory(destinationPath, {}, destDirEntry => {
            sourceFileEntry.moveTo(destDirEntry, filename, resolve, reject);
          }, error => {
            console.error('[move] Error getting destination directory', error);
            reject(error);
          });
        }, error => {
          console.error('[move] Error getting source file', error);
          reject(error);
        });
      });
    }).catch(error => reject(error));
  });
}
Enter fullscreen mode Exit fullscreen mode

The problem here is mainly that we have a deeply nested code, which makes it harder to reason about, maintain and debug.

The strategy

To understand what was going on, what I tried to do is to visually isolate callbacks, identify relevant data we were extracting from each call, and where we were using it.

After that, I wrapped the functions on await and Promise to simulate a regular function that returns a value.

Let's see how we go from a callback based function to an async function.

// you call this `doStuff` function to do something and you get `data` if it
// succeeds or an `error` if it fails.
doStuff(param1, param2,
    data => {
      /* do something with the data */
    },
    error => {
      /* problem with doStuff */
    }
  );

// We can extract our functions to handle success and failure like so:
const onSuccess = data => { /* do something with the data */ }
const onFailure = error => { /* problem with doStuff */ }

doStuff(param1, param2, onSuccess, onFailure);
Enter fullscreen mode Exit fullscreen mode

Now, let's use a Promise to wrap our call and await for its result.

try {
  const data = await new Promise((resolve, reject) => {
    const onSuccess = data => resolve(data);
    const onFailure = error => reject(error);
    doStuff(param1, param2, onSuccess, onFailure);

    // we don't really need a separate definition for the functions
    // we can inline them like so:
    doStuff(param1, param2, data => resolve(data), error => reject(error));
  });

  /* do something with the data */
} catch(error) {
  /* problem with doStuff */
}
Enter fullscreen mode Exit fullscreen mode

Or alternatively, as a one liner.

try {
  const data = await new Promise((resolve, reject) => doStuff(param1, param2, data => resolve(data), error => reject(error)));
  /* do something with the data */
} catch(error) {
  /* problem with doStuff */
}
Enter fullscreen mode Exit fullscreen mode

The success/failure handlers are a bit redundant, so let's simplify that.

try {
  const data = await new Promise((resolve, reject) => doStuff(param1, param2, resolve, reject));
  /* do something with the data */
} catch(error) {
  /* problem with doStuff */
}
Enter fullscreen mode Exit fullscreen mode

And there we go, our final shape. It doesn't look like much of a change, but this allows us to have a more shallow code instead of a really nested set of callbacks.

Final result

Here's how our function looks after refactoring it using the above strategy.

import path from 'path';

/**
 * Moves a file from one directory to another.
 *
 * @param { String } basePath: the base path for both relativeSourcePath
 *                             and relativeDestinationPath.
 * @param { String } relativeSourcePath: the relative path of the file.
 * @param { String } relativeDestinationPath: the relative new path of the file.
 *
 * @throws { Error } if there was a problem moving the file.
 */
async function move(basePath, relativeSourcePath, relativeDestinationPath) {
  const destinationPath = path.dirname(relativeDestinationPath);
  const filename = path.basename(relativeDestinationPath);

  try {
    await ensureDirectory(basePath, destinationPath)

    const baseDirEntry = await new Promise(resolve =>
      window.resolveLocalFileSystemURL(basePath, resolve)
    );

    const sourceFileEntry = await new Promise((resolve, reject) =>
      baseDirEntry.getFile(relativeSourcePath, {}, resolve, reject)
    );

    const destDirEntry = await new Promise((resolve, reject) =>
      baseDirEntry.getDirectory(destinationPath, {}, resolve, reject)
    );

    await new Promise((resolve, reject) =>
      sourceFileEntry.moveTo(destDirEntry, filename, resolve, reject)
    );
  } catch (error) {
    // here you can do something about this problem
    console.error('There was a problem moving the file.', error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

For this particular case, it didn't make much sense to log two different errors, so I wrapped all the calls in a try/catch and just logged the problem there.

Your use case may vary and you may want to have more than one handling block or none at all and document that your function may throw different errors. This is useful if you don't want to perform a specific action on this function when an error occurs, and leave it to the caller.

Last words

With just a little of work, our code is now easier to read and maintain.

This problem is quite common and it's usually called "callback hell", as you can see here: http://callbackhell.com/

I hope this article gives you some ideas on how to make your life easier.

Disclaimer

I wrote this article for the SpiderOak engineering blog and it was published on Jul 10, 2019.
https://engineering.spideroak.com/unraveling-callbacks-with-async-functions/

The original post is licensed as: Creative Commons BY-NC-ND

Top comments (0)