Search code examples
javascripttypescriptasync-awaitpromisefuture

Better alternative to "promisify"-ing?


Let's say I'm writing the following function:

async function foo(axe: Axe): Promise<Sword> {
  // ...
}

It is meant to be used like this:

async function bar() {
  // get an axe somehow ...

  const sword = await foo(axe);

  // do something with the sword ...
}

So far, so good. The problem is that in order to implement foo, I want to call the following "callback-style async" function. I cannot change its signature (it's a library/module):

/* The best way to get a sword from an axe!
 * Results in a null if axe is not sharp enough */
function qux(axe: Axe, callback: (sword: Sword) => void);

The best way I've found to do this is to "promisify" qux:

async function foo(axe: Axe): Promise<Sword> {
  return new Promise<Sword>((resolve, reject) => {
    qux(axe, (sword) => {
      if (sword !== null) {
        resolve(sword);
      } else {
        reject('Axe is not sharp enough ;(');
      }
    });
  });
}

It works, but I was hoping I could do something more direct/readable. In some languages, you could create a promise-like object (I call it Assure here), and then explicitly set its value elsewhere. Something like this:

async function foo(axe: Axe): Promise<Sword> {
  const futureSword = new Assure<Sword>();

  qux((sword) => {
    if (sword !== null) {
      futureSword.provide(sword);
    } else {
      futureSword.fail('Axe is not sharp enough ;(');
    }
  });

  return futureSword.promise;

Is this possible in the language itself, or do I need to use a library/module, like deferred?

Update (1): extra motivation

Why would one prefer the second solution over the first? Because of callback chaining.

What if I wanted to perform multiple steps inside foo, not just call qux? If this was synchronous code, it could look like this:

function zim(sling: Sling): Rifle {
  const bow = bop(sling);
  const crossbow = wug(bow);
  const rifle = kek(crossbow); 
  return rifle;
}

If these functions were async, promisify-ing would give me this:

async function zim(sling: Sling): Promise<Rifle> {
  return new Promise<Rifle>((resolve, reject) => {
    bop(sling, (bow) => {
      wug(bow, (crossbow) => {
        kek(crossbow, (rifle) => {
          resolve(rifle);
        });
      });
    });
  );
}

With an Assure, I could do this:


async function zim(sling: Sling): Promise<Rifle> {
  const futureBow = new Assure<Bow>();
  bop(sling, (bow) => futureBow.provide(bow));

  const futureCrossbow = new Assure<Crossbow>();
  wug(await futureBow, (crossbow) => futureCrossbow.provide(crossbow));

  const futureRifle = new Assure<Rifle>();
  kek(await futureCrossbow, (rifle) => futureRifle.provide(rifle));
 
  return futureRifle;
}

I find this more manageable, since I don't need to keep track of the nested scopes and to worry about the order of computation. If functions take multiple arguments the difference is even larger.

Reflection

That said, I could agree that there is an elegance to the version with the nested calls, because we don't need to declare all these temporary variables.

And while writing this question I got another idea, of how I could keep in stride with the spirit of JavaScript:

function zim(sling: Sling): Rifle {
  const bow = await new Promise((resolve, reject) => { bop(sling, resolve); });
  const crossbow = await new Promise((resolve, reject) => { wug(bow, resolve); });
  const rifle = await new Promise((resolve, reject) => { kek(crossbow, resolve); }); 
  return rifle;
}

... which starts to look a lot like using util.promisify from Nodejs. If only the callbacks were following the error-first convention... But at this point it seems justified to implement a naive myPromisify which wraps promisify and handles the type of callbacks I have.


Solution

  • The Assure Can be implemented pretty simply in TypeScript:

    class Assure<T, U = unknown> {
        public promise: Promise<T>;
        private resolve!: (value: T) => void;
        private reject! : (error: U) => void;
    
        constructor() {
            this.promise = new Promise((resolve, reject) => {
                this.resolve = resolve;
                this.reject = reject;
            });
        }
    
        public provide(value: T) {
            this.resolve(value);
        }
    
        public fail(reason: U) {
            this.reject(reason);
        }
    }
    

    Playground Link with demo

    With that said, it is probably not needed. The usage ends up being basically like using promises, however:

    1. It is not idiomatic.
    2. Does not seem to bring any value over regular promises.

    A much better alternative might be to define a function that can turn callback taking functions into promise returning function. This is also not much work - with TS we can even make sure the converted functions have the correct types. Here is an implementation for the major types of callback taking functions:

    /* utility types */
    /** All but last elements of a tuple: [a, b, c, d] -> [a, b, c] */
    type Initial<T extends any[]> = T extends [ ...infer Initial, any ] ? Initial : any[];
    /** All but first elements of a tuple: [a, b, c, d] -> [b, c, d] */
    type Tail<T extends any[]> = T extends [ any, ...infer Tail ] ? Tail : any[];
    
    /** First elements of a tuple: [a, b, c, d] -> a */
    type Head<T extends any[]> = T extends [ infer Head, ...any[] ] ? Head : any;
    /** Last elements of a tuple: [a, b, c, d] -> d */
    type Last<T extends any[]> = T extends [ ...any[], infer Last ] ? Last : any;
    
    /** First parameter of a function: ( (a, b, c, d) => any ) -> a */
    type FirstParameter<T extends (...args: any[]) => void> = Head<Parameters<T>>;
    /** Last parameter of a function: ( (a, b, c, d) => any ) -> d */
    type LastParameter<T extends (...args: any[]) => void> = Last<Parameters<T>>;
    /* /utility types */
    
    /**
     * Converts an asynchronous function to a promise returning one.
     * The function should have a callback with a result as last parameter
     */
    function promisifyResultCallback<T extends (...args: any[]) => void>(fn: T) {
        type ResultCallback = LastParameter<T>;
        type ReturnType = FirstParameter<ResultCallback>;
    
        return function(...args: Initial<Parameters<T>>): Promise<ReturnType> {
            return new Promise((resolve) => {
                fn(...args, resolve);
            });
        }
    }
    
    /**
     * Converts an asynchronous function to a promise returning one.
     * The function should have a callback with an error and result as last parameter - error-first style like in Node.js
     */
    function promisifyErrorFirstCallback<T extends (...args: any[]) => void>(fn: T) {
        type ResultCallback = LastParameter<T>;
        type ReturnType = LastParameter<ResultCallback>;
    
        return function(...args: Initial<Parameters<T>>): Promise<ReturnType> {
            return new Promise((resolve, reject) => {
                fn(...args, (err: unknown, x: ReturnType) => {
                    if (err) 
                        reject(err);
                    
                    resolve(x);
                });
            });
        }
    }
    
    /**
     * Converts an asynchronous function to a promise returning one.
     * The function should have two callback at the end  for success and error
     */
    function promisifyTwoCallbacks<T extends (...args: any[]) => void>(fn: T) {
        type ResultCallback = Last<Initial<Parameters<T>>>; //second to last
        type ReturnType = FirstParameter<ResultCallback>;
    
        return function(...args: Initial<Initial<Parameters<T>>>): Promise<ReturnType> {
            return new Promise((resolve, reject) => {
                fn(...args, resolve, reject);
            });
        }
    }
    

    Which allows usage like this:

    declare function onlyResultCallback(a: string, b: number, callback: (resut: boolean) => void): void;
    const p_onlyResultCallback = promisifyResultCallback(onlyResultCallback);
    //    ^ type is: (a: string, b: number) => Promise<boolean>
    
    declare function errorFirstCallback(a: string, b: number, callback: (err: Error | null, resut: boolean) => void): void;
    const p_errorFirstCallback = promisifyErrorFirstCallback(errorFirstCallback);
    //    ^ type is: (a: string, b: number) => Promise<boolean>
    
    declare function twoCallbacks(a: string, b: number, onSuccess: (resut: boolean) => void, onError: (err: Error) => void): void;
    const p_twoCallbacks = promisifyTwoCallbacks(twoCallbacks);
    //    ^ type is: (a: string, b: number) => Promise<boolean>
    

    Playground Link

    With a promisify function, foo() can be implemented as simple as:

    declare function qux(axe: Axe, callback: (sword: Sword | null) => void): void;
    
    async function foo(axe: Axe): Promise<Sword> {
      const p_qux = promisifyResultCallback(qux);
      const maybeSword = await p_qux(axe);
    
      if (maybeSword === null)
        throw 'Axe is not sharp enough ;(';
      
      // the maybeSword is narrowed to just a sword since null is eliminated in the `if`
      return maybeSword;
    }
    

    Playground Link

    The assignment to p_qux is just here for demonstration purposes. A more idiomatic code would either make the assignment outside the function once or just directly use const maybeSword = await promisifyResultCallback(qux)(axe);

    if really needed, the promisifying function can be changed to also allow for passing arguments and allow usage like promisifyResultCallback(qux, axe) however, that is left as an exercise of the reader.

    Using promises also eliminates the nesting issue:

    declare function bop( sling   : Sling   , callback: (bow: Bow          ) => void ): void;
    declare function wug( bow     : Bow     , callback: (crossbow: Crossbow) => void ): void;
    declare function kek( crossbow: Crossbow, callback: (rifle: Rifle      ) => void ): void;
    
    async function zim(sling: Sling): Promise<Rifle> {
      return new Promise<Rifle>((resolve, reject) => {
        bop(sling, (bow) => {
          wug(bow, (crossbow) => {
            kek(crossbow, (rifle) => {
              resolve(rifle);
            });
          });
        });
      });
    }
    

    once the functions are promisified:

    const p_bop = promisifyResultCallback(bop);
    const p_wug = promisifyResultCallback(wug);
    const p_kek = promisifyResultCallback(kek);
    

    can be handled as regular promises:

    function zim(sling: Sling) {
        return p_bop(sling)
            .then(bow => p_wug(bow))
            .then(crossbow => p_kek(crossbow))
    }
    

    or

    function zim(sling: Sling) {
        return p_bop(sling)
            .then(p_wug)
            .then(p_kek)
    }
    

    or

    async function zim(sling: Sling) {
        const bow = await p_bop(sling);
        const crossbow = await p_wug(bow);
        const rifle = await p_kek(crossbow);
        return rifle;
    }
    

    Playground Link