How to convert between callbacks and Promises in Javascript

How to use the promisify and callbackify functions, and when they are not enough

Author's image
Tamás Sallai
9 mins

While Promises and async/await are increasingly the primary way to write asynchronous code in Javascript, callbacks are still used in many places. Several libraries that adopted that style are slow to migrate to the more modern alternative, and browser (and Node) APIs are also slow to change.

For example, marked, a markdown compiler needs a callback when it's used in asynchronous mode:

marked(text, options, (err, result) => {
	// result is the compiled markdown
});

Similarly, setTimeout invokes a function when the time is up:

setTimeout(callback, 100);

Not to mention a ton of web APIs, such as Indexed DB, FileReader, and others. Callbacks are still everywhere, and it's a good practice to convert them to Promises especially if your code is already using async/await.

Callback styles

Callbacks implement the continuation-passing style programming where a function instead of returning a value calls a continuation, in this case, an argument function. It is especially prevalent in Javascript as it does not support synchronous waiting. Everything that involves some future events, such as network calls, an asynchronous API, or a simple timeout is only possible by using a callback mechanism.

There are several ways callbacks can work. For example, setTimeout uses a callback-first pattern:

setTimeout(callback, ms);

Or functions can get multiple functions and call them when appropriate:

const checkAdmin = (id, isAdmin, notAdmin) => {
	if (/* admin logic */) {
		isAdmin();
	}else {
		notAdmin();
	}
};

How the callback is invoked can also vary. For example, it might get multiple arguments:

const getUserData = (id, cb) => {
	const user = /* get user */
	cb(user.id, user.profile, user.avatar);
};

Also, asynchronicity might be implemented as an event emitted by an EventEmitter object:

const reader = new FileReader();
reader.onload = (event) => {
	// event.target.result
}
reader.onerror = (error) => {
	// handle error
};
reader.readAsDataURL(blob);

Node-style callbacks

As there are multiple equally reasonable ways to implement callbacks, it was a mess at first. A de-facto standard emerged, which is now called Node-style or error-first callbacks. It is used almost everywhere in Javascript whenever a callback is needed.

A Node-style callback has three characteristics:

  • The callback function is the last argument
  • It is called with an error object first, then a result ((error, result))
  • It returns only one result

As an illustration, this function implements a Node-style callback:

const getUser = (id, cb) => {
	const user = /* get user */;
	if (user) {
		// success
		cb(null, user);
	}else {
		// error
		cb(new Error("Failed to get user"));
	}
};

Notice that when there is no error, the first argument is null. This allows the caller to easily check whether the execution failed or succeeded:

getUser(15, (error, result) => {
	if (error) {
		// handle error
	} else {
		// handle result
	}
});

With a callback structure that is used almost exclusively, it is possible to convert between Promises and callbacks more easily.

Convert callbacks to Promises

This is the more prominent direction as it's a common task to integrate a callback-based function into an async/await flow. Let's see how to do it!

Promise constructor

The Promise constructor is the low-level but universally applicable way to convert callbacks to Promises. It works for every callback style and it needs only a few lines of code.

The Promise constructor gets a function with two arguments: a resolve and a reject function. When one of them is called, the Promise will settle with either a result passed to the resolve function, or an error, passed to the reject.

new Promise((res, rej) => {

	// resolve with a value
	// res(value)

	// reject with error:
	// rej(error)
})

This makes it easy to call a callback-based function and convert it to a Promise which then can be await-ed:

// Node-style
new Promise((res, rej) => getUser(15, (err, result) => {
	if (err) {
		rej(err);
	}else {
		res(result);
	}
}))

// setTimeout
new Promise((res) => setTimeout(res, 100));

// event-based
new Promise((res, rej) => {
	const reader = new FileReader();
	reader.onload = (event) => {
		// resolve the Promise
		res(event.target.result);
	}
	reader.onerror = (error) => {
		// reject the Promise
		rej(error)
	};
	reader.readAsDataURL(blob);
});

Promisified functions

The above examples show how to call a callback-based function and get back a Promise, but it requires wrapping every call with the Promise boilerplate. It would be better to have a function that mimics the original one but without the callback. Such a function would get the same arguments minus the callback and return the Promise.

To make promisified versions of functions, it is only a matter of wrapping the Promise constructor in a function that gets the arguments before the callback:

// setTimeout
const promisifiedSetTimeout = (ms) => new Promise((res) => setTimeout(res, ms));

// FileReader
const promisifiedFileReader = (blob) => new Promise((res, rej) => {
	const reader = new FileReader();
	reader.onload = (event) => {
		res(event.target.result);
	}
	reader.onerror = (error) => {
		rej(error)
	};
	reader.readAsDataURL(blob);
});

// checkAdmin
const promisifiedCheckAdmin = (id) => new Promise((res) => {
	if (/* admin logic */) {
		res(true);
	}else {
		res(false);
	}
};

These functions are direct replacements to the callback-based originals and can be directly used in an async/await workflow:

// timeout
setTimeout(() => {
	console.log("Timeout reached");
}, 100);

await promisifiedSetTimeout(100);
console.log("Timeout reached");

// checkAdmin
const checkAdmin = (id, isAdmin, notAdmin) => {
	if (/* admin logic */) {
		isAdmin();
	}else {
		notAdmin();
	}
};

checkAdmin(15, () => {
	console.log("User is an admin");
}, () => {
	console.log("User is not an admin");
});

const admin = await promisifiedCheckAdmin(15);
console.log(`User is admin: ${admin}`);

// FileReader
const dataURI = await promisifiedFileReader(blob);

util.promisify

While the Promise constructor offers a universal way to transform callbacks to Promises, when the callback pattern follows the Node-style there is an easier way. The util.promisify gets the callback-based function and returns a promisified version.

import util from "util";

const promisifiedGetUser = util.promisify(getUser);

const user = await promisifiedGetUser(15);
// user is the result object

When the promisified function is called, the callback argument is added automatically and it also converts the result (or error) to the return value of the Promise.

How it works is no magic though. It uses the Promise constructor pattern we've discussed above, and uses the spread syntax to allow arbitrary amount of arguments. A simplified implementation looks like this:

const promisify = (fn) => (...args) => new Promise((res, rej) => {
	fn(...args, (err, result) => {
		if (err) {
			rej(err);
		}else {
			res(result);
		}
	});
});

Usual problems

Handle this

The value of this is complicated in Javascript. It can get different values depending on how you call a function.

For example, classes can have instance variables, attached to this:

class C {
	constructor() {
		this.var = "var";
	}
	fn() {
	    console.log(this.var);
	}
}

But when you have an object of this class, whether you call this function directly on the object or extract it to another variable makes a difference in the value of this:

new C().fn(); // var

let fn = new C().fn;
fn(); // TypeError: Cannot read property 'var' of undefined

This affects how to promisify the methods of this object as the util.promisify requires just the function and not the whole object. Which, in turn, breaks this.

For example, let's say there is a Database object that creates a connection in its constructor then it offers methods to send queries:

class Database {
	constructor() {
		this.connection = "database connection";
	}
	getUser(id, cb) {
		if (!this.connection) {
			throw new Error("No connection");
		}
		setTimeout(() => {
			if (id >= 0) {
				cb(null, `user: ${id}`);
			}else {
				cb(new Error("id must be positive"));
			}
		}, 100);
	}
}

const database = new Database();
database.getUser(15, (error, user) => {
	// handle error or user
})

Using util.promisify would break the getUser function as it changes the value of this:

const promisifiedDatabaseGetUser = util.promisify(database.getUser);
await promisifiedDatabaseGetUser(15); // Error: Cannot read property 'connection' of undefined

To solve this, you can bind the object to the function, forcing the value of this:

const promisifiedDatabaseGetUser = util.promisify(database.getUser.bind(database));
const user = await promisifiedDatabaseGetUser(15);

function.length

The length of a function is how many arguments it needs. Don't confuse this with the Array's length, as that is how many elements in the array.

For example, this function needs 2 arguments, so its length is 2:

const fn = (a, cb) => {};

console.log(fn.length); // 2

It is rarely used, but some libraries depend on it having the correct value, such as memoizee, which determines how to cache the function call or marked, a Markdown compiler, to decide whether its configuration is called async or sync.

While the length of the function is rarely used, it can cause problems. util.promisify does not change it, so the resulting function will have the same length as the original one, even though it needs fewer arguments.

console.log(fn.length); // 2

const promisified = util.promisify(fn);

console.log(promisified.length); // 2

Convert Promises to callbacks

The other direction is to have a Promise-returning function (usually an async function) and you need to convert it to a callback-based one. It is much rarer than the other way around, but there are cases when it's needed, usually when a library expects an asynchronous function and it supports only callbacks.

For example, the marked library supports a highlight option that gets the code block and returns a formatted version. The highlighter gets the code, the language, and a callback argument, and it is expected to call the last one with the result.

import marked from "marked";
import util from "util";

const promisifiedMarked = util.promisify(marked);

const res = await promisifiedMarked(md, {
	// highlight gets a callback
	highlight: (code, lang, cb) => {
		const result = "Code block";
		cb(null, result);
	}
});

As with the promisification, there is a universal and a Node-callback-specific way to convert an async function to use callbacks. The universal way is to use an async IIFE (Immediately-Invoked Function Expression) and add use then to interface with the callback when there is a result or an error:

highlight: (code, lang, cb) => {
	(async () => {
		const result = "Code block";
		return result;
	})().then((res) => cb(null, res), (err) => cb(err));
}

This structure allows all callback styles as you control how a result or an error is communicated with the callback function.

For Node-style callbacks, you can use the util.callbackify function that gets an async function and returns a callbackified version:

highlight: util.callbackify(async (code, lang) => {
	const result = "Code block";

	return result;
})

This yields a convenient structure and it is suitable in most cases.

Also, it changes the resulting function's length by one, as it needs a callback as well as all the original arguments:

console.log(fn.length) // 2
console.log(util.callbackify(fn).length) // 3

Conclusion

Promises and async functions are the present, but still many things support only callbacks. While there are many ways to implement callbacks, the Node-style pattern is prevalent in Javascript. This allows utility functions to convert between Promises and callbacks mostly automatically.

January 26, 2021