Rewrite Your Callbacks to Promises

29 Jul 2019

In my Angular project with promises everywhere, we should avoid using callbacks. You may have many cutting-edge stuffs based on ES6 in your project, but callbacks are just like disgusting flies in the delicious soup. So you might want to refactor your code to use promises only.

Simple rewrites: Use setTimeout / setInterval and async / await

For the function in your project, it’s pretty easy to just return the promise. setTimeout / setInterval is a very common case of callback function. While if you are using a framework, you should avoid using setTimeout / setInterval. Because you have component lifecycle, that’s where you should differentiate the timing of your tasks. But my case is that I am working on the script injected to an unknown webpage. I cannot use framework. Anyway, here is the way to rewrite setTimeout:

Copy
const delay = t => new Promise(resolve => setTimeout(resolve, t));

async / await is the way that I recommend to consider if the logic contains multiple steps. Your program will become very concise if you use it. Let me show you how good it is when using the function delay with await:

Copy
await delay(1000);  
while (checkIfReachedLimit(param1, param2)) {  
  doItOnce(param3); 
  await delay(500);  
}

This snippet is to do an action over and over again until reaching the limit. It’s pretty concise, and, pretty safe too. await just divides your function into different switch cases after the code is compiled to ES5 standard, you don’t need to worry about the scope issue, whether param 1, 2 and 3 are accessible or up-to-date. But if you use setTimeout directly, it becomes pretty complicated. If setTimeout is within the while loop, every time it will add an event to JavaScript event loop queue. Add you need to think about what is the status of the params… That’s a headache!

Not my job…?

I am working on browser extension. So lots of functions that I used, are provided by Chrome. In their API document, they only provide callback as a parameter. You probably experience the same story when using some other 3rd party libraries. So it’s not my job… isn’t it? We can still refactor those functions!

This is how to do it for chrome.tabs.create:

First, the signature of the function in Chrome’s API document is:

chrome.tabs.create(object createProperties, function callback)

Promise version:

Copy
openNewTab(url: string, active?: boolean): Promise<TabState> {
  return new Promise((res, rej) => {
    chrome.tabs.create({ url: url, active: active },
      (tab: chrome.tabs.Tab) => {
        var error = chrome.runtime.lastError;
        if (error) {
          console.error('Failed to create tab:', error);
            rej(error);
        }
        else {
          console.log(`Created tab with id ${tab.id}`; // Do your things
          res();
        }
      }
    );
  });
}

The idea is simple: Return a new promise. Reject it if having error, otherwise resolve it. The implementation is also simple: await this.openNewTab('https://pike96.com', true);. I believe this one-line implementation will make your code very concise.

Here Chrome and other browsers’ API has a special error handler. The error information is stored in chrome.runtime.lastError. So I need to get and print the error information before reject the promise.

After I use this method for chrome.tabs.query, chrome.tabs.remove, chrome.alarms.clear, I am not satisfied with this method also. The error handler seems verbose. That leads to one step further:

Improvement: callbackDecorator

Talk is cheap, show you the code:

Copy
/**
 * This function converts all callbacks to promises and handle error
 * @param errorFuncName: The function name in error message.
                         e.g. browserApi.tabs.get
 * @param func: The function to execute. e.g. chrome.tabs.create
 * @param params: The parameter array to be passed in func
 * @param callback (optional):
     The rewritten callback function. It takes func's results as parameters.
 *   Can put any logic in it, then returns the item that needs to be resolved.
 */
public callbackDecorator = (
    errorFuncName: string,
    func: Function,
    params: any[],
    callback?: Function
) => {
  const paramsCopy = params; // To show parameters if having error
  return new Promise((res, rej) => {
    // callbackArgs: Parameters passed into callback function
    func(...params, (...callbackArgs) => {
      const error = chrome.runtime.lastError;
      if (error) {
        console.error(
          `Chrome API Error: ${errorFuncName}(${paramsCopy.join(', ')}):`,
          error.message);
        rej(error);
      }
      else if (callback) {
        res(callback(...callbackArgs));
      }
      else {
        res();
      }
    });
  });
};

Implementation:

Copy
this.xxxService.callbackDecorator('tabs.create', chrome.tabs.create,  
  [{ url, active }], (tab: chrome.tabs.Tab) => 
    console.log(`Created tab with id ${tab.id}` // Do your things
);

A confusing pair of names in this function is params and callbackArgs:

  • params is the parameters to be passed into chrome.tabs.create. paramsCopy is to avoid confusion.
  • callbackArgs is the parameters of the callback function.

Here Spread syntax is used for unknown parameters, and this is literally a lifesaver: The callbackArgs can have any number of parameters including 0. callback is still optional which is the same as the original function signature in Chrome’s API document. To make it easier, I didn’t set params as optional because callback must be optional. So if no parameter should be passed into the function. params has to be [].

It is how I do it in my case. I want to encapsulate the error handler. You can encapsulate anything that happens before rej() or before res(). You can have callback in rej() also. Probably you will save much more lines of code in you project!

Note: I have another article expirable-synchronized (A JS decorator to safely make function atomic), I call both “decorator”. You may notice that they looks very different. Because @expirableSynchronized is a decorator in annotation, while this callbackDecorator function is a decorator inline. I would rather call @expirableSynchronized “annotation” if the documentation doesn’t call it “decorator”.

Rewrite Your Callbacks to Promises
Home Page