Originally published at www.melvinkoh.me.
Before ES6 provides us the native support of Promise, we rely on third-party libraries like Bluebird to enable this useful mechanism. In this post, we will see how we can promisify callback-based API in the ES6 native way and avoiding a common anti-pattern.
Promisify is to refactor conventional Callback-based API into Promise-based API.
Traditionally, callback-based API are used to handle asynchronous function call. A callback-based API typically expects 2 arguments: onSuccessCallback, and onErrorCallback.
// Typical callback-based APIfunction send (message, onSuccessCallback, onErrorCallback) { try { // Do something with the message } catch (error) { onErrorCallback(error) }
onSuccessCallback()}
If something gone wrong, the catch block will invoke the onErrorCallback() that we passed into it during function call. If everything runs without error, the onSuccessCallback() is called.
The problem of a Callback-based API is we can’t chain a series of callbacks without writing a nasty code. In some situation, there isn’t a clean way of implementing our logic without promisifying it.
This is indeed a problem I faced when I was writing a XMPP Chat Client in ES6 and integrating it with my Vue.js-based front-end.
Strophe.js is a XMPP js library that handles establishment of XMPP session and communication with XMPP Chat Server. The purpose of Strophe.js is not important in this example, as my intention is to illustrate problems of a conventional Callback-based API.
This is a simplified method signature of connect() in Stophe.js to establish a connection to XMPP server:
connect: function ( jid, pass, callback) { ... }
jid and pass is the username and password equivalent in XMPP. Whenever we call this method, a third positional argument callback is expected to handle stanza (you can think stanza as message) replied by the server.
Typically, it takes a few stanzas to establish a connection before you could start using the connection. If we want to use the the connection right after we call connect(), we can’t, as by the time you invoke connect(), it is not guaranteed that the connection is successfully established. It will result in error if you try to call any function to use the connection before its establishment.
This is a simplified logic when establishing a connection using connect():
import Strophe from 'strophe.js'
conn = Strophe.Connection(...)
conn.connect( 'myJID', 'myPwd', (status) => { switch (status) { case Strophe.Status.CONNECTED: console.log('YEAH! Connection established.') break case Strophe.Status.DISCONNECTED: console.log('Oh No... Connection disconnected.') break } })
conn.send('something') // Result in error as connection is not ready
If we want to use the above connection right after invoking connect(), you will probably get a Connection Error. So, how can we chain a series of method call without calling them from the callback passed into connect()?
The simplest way is to promisify the code above.
There is some API expecting only a single callback to handle all the possible situations:
const onConnect = function (status) { if (status === 'ok') { console.log('ok') } else { console.log('not ok') }}
const connect = function (onConnectCallback) {...}
We call the above function by:
connect(onConnect)
To promisify connect(), we refactor it to the following:
const connectPromisified = function (jid, pwd) { return new Promise((resolve, reject) => { connect( jid, pwd, (status) => { if (status === 'ok') { console.log('ok') resolve() } else { console.log('not ok') reject() } } ) }) }
TADA! You can use it in a Promisified manner.
connect(onConnect) .then(() => { /** * This anonymous function will be called whenever the Promise is resolved. * In other word, the connection will be used only if the connection is successfully established */
conn.send('something') })
This API expects 2 callbacks (onSuccess, onError):
const sendQuery = function (query, onSuccess, onError) {...}
To make the sendQuery() a Promise, we wrap it with another method:
const sendQueryPromisified = function (query) { return new Promise((resolve, reject) => { sendQuery(query, resolve, reject) })}
The resolve() is now the onSuccess callback and reject() becomes the onError callback.
By refactoring so, we can use it as a regular Promise:
sendQueryPromisified('a query').then(() => { /* handle onSuccess here*/ }).catch(() => { /* handle onError here */ })
Using the example of our Strophe Connection above, we refactor the connect() into this:
const connect = function (jid, pwd) { conn.connect(jid, pwd, (status) => { return new Promise((resolve, reject) => { switch (status) { case Strophe.Status.CONNECTED: console.log('YEAH! Connection established.') resolve() break case Strophe.Status.DISCONNECTED: console.log('Oh No... Connection disconnected.') reject() break }) }) }
Thus, we can call the connect() in this manner:
connect(jid, pwd).then(() => { /* connection established */ /* start using your connection here */ }).catch(() => { /* handle errors here */ })
Developers who are accustomed to using callback-based API tend to make this mistake. Let’s look into the method signature of then():
then (onFulfilled, onRejected) {...}
The then() provides you the facility to inject a onError/onRejected callback to handle an error return from a Promise. Some might use it this way:
const onSuccess = function () {...}
const onError = function () {...}
connect().then(onSuccess, onError)
Do you spot the similarity of the then() with your regular callback-based API?
You should not use then() this way, or you are simply making a Promise-based API to Callback-based. You should rely on catch() to handle errors in a Promise.
Your clap will definitely drive me further. Give me a clap if you like this post.