Home > Net >  Is it possible to create type safety on a function that creates a promise out of a callback and a li
Is it possible to create type safety on a function that creates a promise out of a callback and a li

Time:09-25

I have defined a function that takes another function and a list of arguments as parameters to create a promise, like so:

  async callbackToPromise (func: Function, ...args: any[]): Promise<any> {
    // Immediately return if the function argument is undefined, to avoid errors.
    if (func === undefined) {
      console.warn('Function undefined in callbackToPromise.')
      return await Promise.reject(
        new Error('Function undefined in callbackToPromise.')
      )
    }
    const call = new Promise((resolve, reject) => {
      func((resolveStr: unknown) => {
        if (resolveStr !== undefined) {
          return resolve(resolveStr)
        } else {
          return reject(new Error('No data returned'))
        }
      }, ...args)
    })
    return await call
  }

I use this function to get promises out of a not insignificant variety of API calls in my environment that all take a callback as the first parameter and return nothing (just calling the callback with data instead). These have a variety of additional parameters and call the callback with a variety of return types.

It generally works well, but I run into some frustration sometimes when writing up new API calls that I haven't used before as Typescript can't tell me which parameters to pass for ...args and I have to spend extra time checking against the typing I built for the API to know exactly what to pass in and how.

In use, the functions that call callbackToPromise all define their own parameters and that's what I use outside of the interface code, but it would be more convenient when defining a new interface function if I could keep type safety there, too. And be less error prone if I realize a typing in my API type file is wrong or incomplete and needs to be updated.

Is there any way to tell Typescript "only accept ...args matching the parameters of the function I pass in as func?

Update: more details.

I am calling callbackToPromise with black box functions I have no access to, for example one with a signature like this (which lives as a method of window.external):

  RemoveProblem: (
    callback: Function,
    prid: number,
    stopDate: string,
    isApproxDate: boolean,
    reason: TProblemRemoveReason
  ) => void

An example of how I am using it in my code (part of a longer function definition):

  const result: number = await this.helpers
      .callbackToPromise(
        this.wrappedWindow.external.RemoveProblem,
        prid,
        stopDate,
        isApprox,
        reason
      )
      .catch((error: Error) => {
        console.error(`Error removing problem: ${error.message}`)
      })

What I would like, ideally, would be for callbackToPromise to give a type error on compile if I try to pass parameters that don't match the function I pass it as the first argument.

Update2: I have a partial answer based on @CRice 's answer below, but get a weird issue when I try to actually call the function with an await, Type 'number | void' is not assignable to type 'number'. Type 'void' is not assignable to type 'number'. I could make result in this case not typed and then it passes the error on to everywhere I use result assuming it is a number, unless I add a check for undefined.

If I don't type result at all, though, it implicitly becomes a number. Weird.

Playground based on CRice's second example showing the error

CodePudding user response:

First I'll point out that if you are using nodejs, you can use the built in util.promisify function for this purpose and it comes with correct types already. In the broswer, there will be plenty of packages you can use for the same effect. However, you can also modify your function to infer the promise type by using generics.

This makes heavy use of the helper type Parameters<F> which extracts the type of the parameters of the type F (assuming F is a function type).

The essential part of it is that you can use the Parameters helper type to extract the type of the first parameter of the callback that your function will accept. This is the type that your promise will resolve to.

const callbackToPromise = async <A extends any[], F extends (CB: (result: any) => any, ...args: A) => any>(func: F, ...args: A): Promise<Parameters<Parameters<F>[0]>[0]> => {
    // Immediately return if the function argument is undefined, to avoid errors.
    if (func === undefined) {
        console.warn('Function undefined in callbackToPromise.')
        return await Promise.reject(
        new Error('Function undefined in callbackToPromise.')
        )
    }
    const call = new Promise((resolve, reject) => {
        func((resolveStr) => {
            if (resolveStr !== undefined) {
                return resolve(resolveStr)
            } else {
                return reject(new Error('No data returned'))
            }
        }, ...args)
    })
    return call
}

There is a bit to unpack there, but here is what is going on:

  • A extends any[] declares a generic A which is an array of any other types. This will be used in a moment to represent the type of the all but the first arguments to func.
  • F extends (CB: (result: any) => any, ...args: A) => any declares an additional generic F, which is a function that accepts a callback as its first parameter and then uses the earlier generic A to represent all the remaining parameters.

Finally the return type:

  • Promise<Parameters<Parameters<F>[0]>[0]> just says that the promise will resolve to the type of the first argument of the callback that the function F accepts.

Using that definition, you seem to get the correct inferences when using it:

const numericCallback = (cb: (v: number) => void, num: number): void => {
    cb(num);
}

const promisifyNumericCallback = callbackToPromise(numericCallback, 56) // This is inferred as a Promise<number>

Playground Link

Playground Link 2

  • Related