Converting Callbacks to Promises in Node.js

Introduction

A few years back, callbacks were the only way we could achieve asynchronous code execution in JavaScript. There were few problems with callbacks and the most noticeable one was "Callback hell".

With ES6, Promises were introduced as a solution to those problems. And finally, the async/await keywords were introduced for an even more pleasant experience and improved readability.

Even with the addition of new approaches, there are still a lot of native modules and libraries that use callbacks. In this article, we are going to talk about how to convert JavaScript callbacks to Promises. Knowledge of ES6 will come in handy since we'll be using features such as spread operators to make things easier.

What is a Callback

A callback is a function argument that happens to be a function itself. While we can create any function to accept another function, callbacks are primarily used in asynchronous operations.

JavaScript is an interpreted language that can only process one line of code at a time. Some tasks can take a long while to complete, like downloading or reading a large file. JavaScript offloads these long-running tasks to a different process in the browser or Node.js environment. That way, it doesn't block all the other code from being executed.

Usually, asynchronous functions accept a callback function, so that when they're complete we can process their data.

Let's take an example, we'll write a callback function which will be executed when the program successfully reads a file from our hard disk.

To this end, we'll use a text file called sample.txt, containing the following:

Hello world from sample.txt

Then let's write a simple Node.js script to read the file:

const fs = require('fs');

fs.readFile('./sample.txt', 'utf-8', (err, data) => {
    if (err) {
        // Handle error
        console.error(err);
          return;
    }

    // Data is string do something with it
    console.log(data);
});

for (let i = 0; i < 10; i++) {
    console.log(i);
}

Running this code should yield:

0
...
8
9
Hello world from sample.txt

If you run this code, you should see 0..9 being printed before the callback executed. This is because of the asynchronous management of JavaScript that we have talked about earlier. The callback, which logs the file's contents, will only be called after the file is read.

As a side note, callbacks can be used in synchronous methods as well. For example, Array.sort() accepts a callback function which allows you to customize how the elements are sorted.

Functions that accept callbacks are called higher-order functions.

Now we have a better idea of callbacks. Let's move on and see what a Promise is.

What is a Promise

Promises were introduced with ECMAScript 2015 (commonly known as ES6) to improve the developer experience with asynchronous programming. As its name suggests, it's a promise that a JavaScript object will eventually return a value or an error.

A promise has 3 states:

  • Pending: The initial state indicating that the asynchronous operation is not complete.
  • Fulfilled: Meaning that the asynchronous operation completed successfully.
  • Rejected: Meaning that the asynchronous operation failed.

Most promises end up looking like this:

someAsynchronousFunction()
    .then(data => {
        // After promise is fulfilled
        console.log(data);
    })
    .catch(err => {
        // If promise is rejected
        console.error(err);
    });

Promises are important in modern JavaScript as they're used with the async/await keywords that were introduced in ECMAScript 2016. With async/await, we don't need to use callbacks or then() and catch() to write asynchronous code.

If the previous example were to be adapted, it would look like this:

try {
    const data = await someAsynchronousFunction();
} catch(err) {
    // If promise is rejected
    console.error(err);
}

This looks a lot like "regular" synchronous JavaScript! You can learn more about async/await in our article, Node.js Async Await in ES7.

Most popular JavaScript libraries and new projects use Promises with the async/await keywords.

However, if you're updating an existing repo or encounter a legacy codebase, you would probably be interested in moving callback-based APIs to a Promise based APIs to improve your development experience. Your team will also be thankful.

Let's look at a couple of methods to convert callbacks to promises!

Converting a Callback to a Promise

Node.js Promisify

Most of the asynchronous functions that accept a callback in Node.js, such as the fs (file system) module, have a standard style of implementation - the callback is passed as the last parameter.

For example here is how you can read a file using fs.readFile() without specifying the text encoding:

fs.readFile('./sample.txt', (err, data) => {
    if (err) {
        console.error(err);
          return;
    }

    // Data is a buffer
    console.log(data);
});

Note: If you specify utf-8 as the encoding you will get a string output. If you don't specify the encoding you will get a Buffer output.

Moreover, the callback, which is passed to the function, should accept an Error as it's the first parameter. After that, there can be any number of outputs.

If the function that you need to covert to a Promise follows those rules then you can use util.promisify, a native Node.js module that coverts callbacks to Promises.

In order to do that, first import the util module:

const util = require('util');

Then you use the promisify method to covert it to a promise:

const fs = require('fs');
const readFile = util.promisify(fs.readFile);

Now use the newly created function as a regular promise:

readFile('./sample.txt', 'utf-8')
    .then(data => {
        console.log(data);
    })
    .catch(err => {
        console.log(err);
    });

Alternatively, you can use the async/await keywords as given in the following example:

const fs = require('fs');
const util = require('util');

const readFile = util.promisify(fs.readFile);

(async () => {
    try {
        const content = await readFile('./sample.txt', 'utf-8');
        console.log(content);
    } catch (err) {
        console.error(err);
    }
})();

You can only use the await keyword inside a function that was created with async, hence why we have a function wrapper in this example. This function wrapper is also known as a Immediately Invoked Function Expressions.

If your callback does not follow that particular standard, don't worry. The util.promisify() function can allow you to customize how the conversion happens.

Note: Promises became popular soon after they were introduced. Node.js has already converted most, if not all, of its core functions from a callback to a Promise based API.

If you need to work with files using Promises, use the library that comes with Node.js.

So far you've learnt how to covert Node.js standard style callbacks to promises. This module is only available on Node.js from version 8 onwards. If you're working in the browser or an earlier version of Node, it would likely be best for you to create your own promise-based version of the function.

Creating Your Promise

Let's talk about how to covert callbacks to promises if the util.promisify() function is not available.

The idea is to create a new Promise object that wraps around the callback function. If the callback function returns an error, we reject the Promise with the error. If the callback function returns non-error output, we resolve the Promise with the output.

Let's start by converting a callback to a promise for a function that accepts a fixed number of parameters:

const fs = require('fs');

const readFile = (fileName, encoding) => {
    return new Promise((resolve, reject) => {
        fs.readFile(fileName, encoding, (err, data) => {
            if (err) {
                return reject(err);
            }

            resolve(data);
        });
    });
}

readFile('./sample.txt')
    .then(data => {
        console.log(data);
    })
    .catch(err => {
        console.log(err);
    });

Our new function readFile() accepts the two arguments that we've been using to read files with fs.readFile(). We then create a new Promise object that wraps over the function, which accepts the callback, in this case, fs.readFile().

Instead of returning an error, we reject the Promise. Instead of logging the data immediately, we resolve the Promise. We then use our Promise-based readFile() function like before.

Let's try another function that accepts a dynamic number of parameters:

const getMaxCustom = (callback, ...args) => {
    let max = -Infinity;

    for (let i of args) {
        if (i > max) {
            max = i;
        }
    }

    callback(max);
}

getMaxCustom((max) => { console.log('Max is ' + max) }, 10, 2, 23, 1, 111, 20);

The callback parameter is also the first parameter, making it a bit unusual with functions that accept callbacks.

Converting to a promise is done in the same way. We create a new Promise object that wraps around our function that uses a callback. We then reject if we encounter an error and resolve when we have the result.

Our promisified version looks like this:

const getMaxPromise = (...args) => {
    return new Promise((resolve) => {
        getMaxCustom((max) => {
            resolve(max);
        }, ...args);
    });
}

getMaxCustom(10, 2, 23, 1, 111, 20)
    .then(max => console.log(max));

When creating our promise, it doesn't matter if the function uses callbacks in a non-standard way or with many arguments. We have full control of how it's done and the principles are the same.

Conclusion

While callbacks have been the default way of leveraging asynchronous code in JavaScript, Promises are a more modern method which developers believe is easier to use. If we ever encounter a codebase that uses callbacks, we can now make that function a Promise.

In this article you first saw how to use utils.promisfy() method in Node.js to convert functions that accept callbacks into Promises. You then saw how to create your own Promise object that wraps around a function that accepts a callback without the use of external libraries.

With this, a lot of legacy JavaScript code can easily be intermingled with more modern codebases and practices! As always the source code is available on GitHub.