Search code examples
typescriptasync-awaitpromise

Is it possible to create "promisified" type from function type, stripping anything in arguments that looks like callback and leaving the rest?


I'm thinking about writing some "promisified" equivalents of functions and having trouble understanding how can I get all parameters except those that does look like callback (they usually have 2 arguments and look like that: (error, result) => void.

Those functions are in different module and they are defined in .d.ts files. Meaning it would be very useful to get those arguments because some of them are inside module as private types.

Examples of such functions types (you can notice callback can be anywhere, as well as there can be multiple callbacks):

type FuncLoad1 = (arg1: string, arg2: string, callback: (error: string | null, result: string | null) => void) => void;

type FuncLoad2 = (arg1: string, callback: (error: string | null, result: string | null) => void, arg2: string) => void;

type FuncLoad3 = (arg1: string, callbackInProgress: (progress: number) => void, arg2: string, callbackOnCompleted: (error: string | null, result: string | null) => void) => void;

Is it possible to obtain automatically this type from one above?

(arg1: string, arg2: string) => Promise<string | null>

As you can notice, there can be any number of callbacks and they can be anywhere. Arguments that are not function is all that is left in the processed type.


Solution

  • There is unfortunately no reasonable way to accomplish this as stated.


    It's easy enough to write a type function which removes all the callback-like parameters from a tuple type:

    type TupleExclude<T extends any[], U, A extends any[] = []> =
      T extends [infer F, ...infer R] ? TupleExclude<R, U, F extends U ? A : [...A, F]> : A;
    

    And then you can have a utility type that returns a Promise of the second parameter of any callback in that parameter list:

    type Promisify<T extends (...args: any) => void> =
      (...args: TupleExclude<Parameters<T>, (e: any, r: any) => void>) =>
        Promise<Parameters<Extract<Parameters<T>[number], (e: any, r: any) => void>>[1]>;
    
    type PFL1 = Promisify<FuncLoad1>;
    // type PFL1 = (args_0: string, args_1: string) => Promise<string | null>
    
    type PFL2 = Promisify<FuncLoad2>;
    // type PFL2 = (args_0: string, args_1: string) => Promise<string | null>
    
    type PFL3 = Promisify<FuncLoad3>;
    // type PFL3 = (args_0: string, args_1: string) => Promise<string | null | undefined>
    

    This is fairly close to what you're asking for, except for the return type of PFL3. The main issue here is that there's no simple way to distinguish between the (progress: number) => void and (error: string | null, result: string | null) => void types to say that the second one is "the" callback you care about. The former only declares a single parameter, but functions of fewer parameters are assignable to functions of more parameters, this making them compatible.

    It's potentially possible to imagine really digging through the type system to try to find "the" callback for that example, but then what happens if you have multiple two-arg callbacks? You can't use the names of the parameters like error and result. Parameter names are intentionally unobservable in the type system (see microsoft/TypeScript#55736). But even if you could do this, you should back up and try to imagine implementing a promisify function that works this way.


    Presumably you want to write a function of the form

    declare function promisify<T extends (...args: any)=>void>(f: T): Promisify<T>;
    

    which lets you call

    declare const: fl3: FuncLoad3;
    const pfl3 = promisify(fl3);
    pfl3("abc", "def");
    

    Well, it's going to need a runtime implementation. That implementation has no access to TypeScript's type system, which is erased by the time JavaScript code runs. So it has to somehow call fl3 using the arguments "abc" and "def". How would it know how many callbacks fl3 expects and where to put them? The type (arg1: string, callbackInProgress: (progress: number) => void, arg2: string, callbackOnCompleted: (error: string | null, result: string | null) => void) => void; does not exist at runtime, and a function object at runtime doesn't expose much information about what parameters it takes (there's the length property of functions, but all that tells you is the number of required parameters). There just isn't enough information at runtime for you to do anything.

    So that's the answer to the question as asked; this is not really possible in the type system and it's completely impossible at runtime.


    If you wanted to convert this into something possible, you'd have to come up with some way of telling your function where the callbacks are expected to go. The easiest thing to do is to just declare: there's going to be a single callback and it's going to be at the end, and it's going to expect the result as the second argument. If you did that, things proceed quite directly:

    function promisify<A extends any[], R>(f:
      (...args: [...A, (error: any, result: R) => void]) => void
    ): (...args: A) => Promise<R>;
    function promisify(f: Function) {
      return (...args: any) => new Promise((resolve, reject) => {
        f(...args, (err: any, result: any) => {
          if (err == null)
            resolve(result);
          else
            reject(err);
        })
      })
    }
    
    function funcLoad(arg1: string, arg2: string,
      cb: (error: string | null, result: string | null) => void) {
      setTimeout(() => cb(null, arg1 + " " + arg2), 1000);
    }
    const p = promisify(funcLoad);
    // const p: (arg1: string, arg2: string) => Promise<string | null>
    async function foo() {
      const val = await p("abc", "def");
      console.log(val?.toUpperCase());
    }
    foo(); // "ABC DEF"
    

    You could even write something where promisify takes some sort of options object that alters the quantity and/or locations of the callbacks and consults it. But then you might as well just wrap your function to conform to the "single-callback-at-end" form:

    // FuncLoad3, not the expected form
    function funcLoad3(arg1: string,
      callbackInProgress: (progress: number) => void, arg2: string,
      callbackOnCompleted: (error: string | null, result: string | null) => void) {
      setTimeout(() => {
        callbackInProgress(123);
        setTimeout(() => callbackOnCompleted(null, arg1 + " " + arg2), 1000);
      }, 1000);
    }
    
    // just wrap it
    const wrapFunc3 = (arg1: string, arg2: string,
      cb: (error: string | null, result: string | null) => void) =>
      funcLoad3(arg1, (n) => console.log(n), arg2, cb);
    
    // now it works
    const p3 = promisify(wrapFunc3);
    async function bar() {
      const val = await p3("ghi", "jkl");
      console.log(val?.toUpperCase());
    }
    bar(); // 123, then "GHI JKL"
    

    But all of this is a digression from the question as asked, so I won't go into any detail about how that works.

    Playground link to code