1

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.

danronmoon
  • 3,814
  • 5
  • 34
  • 56
stanm
  • 3,201
  • 1
  • 27
  • 36
  • 3
    looks like your `Assure` is just an abstracted promisify. – pilchard Apr 25 '23 at 09:36
  • 1
    I would argue that it is a decision in the language itself (JavaScript) to not expose the resolve / reject outside of the Promise itself, you can read more about it here: https://stackoverflow.com/questions/26150232/resolve-javascript-promise-outside-the-promise-constructor-scope – AngYC Apr 25 '23 at 09:37
  • 3
    Use a promise library that exposes resolve/reject. Or write your own simple wrapper that does it. TBH, I'm not really sure why the second code is any more preferable to the the first. I'd have just used used a generic `promisify` helper function instead. It's available in Node.js by default through `utils.promisify()` – VLAZ Apr 25 '23 at 09:40
  • 1
    Introducing a library with a different promise type just to write `promisify` in a slightly different way doesn’t seem worth it to me. Like VLAZ said, you probably want a `promisify` function, but the manual version is perfectly readable to anyone familiar with promises. – Ry- Apr 25 '23 at 09:42
  • Ah, I knew I should motivate my preference a little better. Writing an update... (1) – stanm Apr 25 '23 at 09:54
  • 1
    Thanks, @AngYC, this is the question I was looking for, but didn't find, so I wrote my own. So the answer is basically "no, it is not possible in the language itself." – stanm Apr 25 '23 at 09:58
  • 1
    "*Because of callback chaining.*" that's because your approach is wrong. You're falling into the trap of [Aren't promises just callbacks?](https://stackoverflow.com/q/22539815) whereas, if you promisify each function, then the chain auto-flattens and you can easily sequence them together: https://tsplay.dev/mp04am – VLAZ Apr 25 '23 at 10:58
  • 1
    The last paragraph of the question has the answer. Create one promisfy function that can do the job with this specific function signature. – trincot Apr 25 '23 at 12:32

1 Answers1

2

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

VLAZ
  • 26,331
  • 9
  • 49
  • 67