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));
});
}
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);
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 */
}
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 */
}
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 */
}
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;
}
}
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