Can someone break down the steps of transforming a callback function into a async/await with the proper typing to correctly infer the return type?

Solution 1:

My inclination would be to give awaitify the following types:

function awaitify<A extends any[], V>(
    func: (...args: [...A, (e: Error | null, v?: V) => any]) => void, ...args: A) {
    return new Promise<V>((resolve, reject) => {
        func(...args, (err: Error | null, value: V | undefined) => {
            if (err) {
                reject(err);
            } else {
                resolve(value!);
            }
        });
    });
}

Essentially it is generic in both A, the list of non-callback arguments, and V, the type of the value passed in when the callback is called with a non-Error input.

Then the func input is of type (...args: [...A, (e: Error | null, v?: V) => any]) => void, meaning the initial arguments are all of the non-callback arguments A and its final argument is a callback that accepts up to two values. Note that this is using a rest parameter whose type is a tuple of value with a leading rest element. (I'm not going to go into detail for how those work; the TS handbook links inline here should explain it.)

Anyway, the intent with the callback cb you pass to func is that you'd call that it like cb(null, val) for successes, and cb(someError) for failures. But the typing here would also accept "incorrect" things like cb(null) or cb(someError, val). It is possible to be stricter (such as (...[e, v]: [Error, undefined?] | [null, V]) => any) but then it would be consequently harder to use.

After the func argument, awaitify expects a list of arguments of type A. And then awaitify returns a value of type Promise<V>.


Here's a demo of how one might use it:

function foo(
    a: string | null, b: string | null, c: string[] | null,
    callback: (e: Error | null, x?: string | string[]) => void) {
    if (a) {
        callback(null, a);
    } else if (b) {
        callback(null, b);
    } else if (c) {
        callback(null, c);
    } else {
        callback(new Error("error"));
    }
}

const result = await awaitify(foo, null, null, ["1", "2", "3"]);
// const result: string | string[]
console.log(result) // ["1", "2", "3"]

Here result is inferred to be a value of type string | string[], which is what the callback parameter of foo expects as its second argument. That's about the best we can do here, since the compiler can only inspect the call signature for foo(), which looks like

// function foo(a: string | null, b: string | null, c: string[] | null,
//   callback: (e: Error | null, x?: string | string[]) => void): void

and therefore it has no idea that the result will be a string[] and not a string. Even if you make foo() more detailed by having it as an overload accounting for each possibility separately, the awaitify function would not be able to understand this.


And even this depends strongly on you writing out the proper type for your foo() function arguments. You essentially need to manually annotate that callback parameter as callback: (err: Error | null, value?: SomeType) => void if you want the result of awaitify() to be Promise<SomeType>.

It would be nice if it could somehow be inferred from how you use it inside the implementation, but that's not how type inference works. To put it more simply, the compiler cannot infer x in the following:

function baz(x) {  console.log(x.toUpperCase()) } // nope

It just gets upset that you haven't given x a type under the --noImplicitAny compiler flag. Similarly you can't just say foo(a: string | null, b: string | null, c: string[] | null, callback) {} and hope that callback will be inferred from its uses. And you can't get around it by giving it a weaker type annotation like any or Function. I mean, you can, but then awaitify will have no idea what the returned promise type is.

To be painfully clear, you can't avoid the following:

function foo(
    a: string | null, b: string | null, c: string[] | null,
    callback: (e: Error | null, x?: string | string[]) => void) {
// -------------------------------> ^^^^^^^^^^^^^^^^^
// this is where V comes from
}

This may or may not be good enough for your use cases.

Playground link to code