Home > OS >  How to let Typescript know the type of Generic T, when T is the return type of nested functions?
How to let Typescript know the type of Generic T, when T is the return type of nested functions?

Time:10-17

Sorry for the mouthful title. I've searched for quite some time for this problem but unfortunately found none.

Here is the code:

import { readdir } from "fs/promises"

export function tryGate<FUNC_RES, OUTPUT>(func: () => Promise<FUNC_RES>) {
    return {
        onPass(onPass: (res: FUNC_RES) => OUTPUT) {
            return {
                onThrow(onThrow: (catched: unknown) => OUTPUT) {
                    return {
                        async exec() {
                            try {
                                const res = await func()
                                return onPass(res)
                            } catch (e: unknown) {
                                return onThrow(e)
                            }
                        },
                    }
                },
            }
        },
    }
}

const test = tryGate(async () => await readdir("...", "utf8") ) 
    .onPass((paths) => paths)
    .onThrow(()=>[])
    .exec()

The type of test should be Promise<string[]>, because:

  1. await readdir("...", "utf8") returns string array, which should be the type of generic FUNC_RES.
  2. Both onPass and onThrow functions return string[], which should be the type of generic OUTPUT.

However the type of test is Promise<unknown>.

Clearly I could explicitly declare types as such:

const test = tryGate<string[], string[]>(async () => await readdir("...", "utf8") ) 
    .onPass((paths) => paths)
    .onThrow(()=>[])
    .exec()

Now it works as intended. But I think there must the way to omit <string[], string[]> somehow...

CodePudding user response:

In what follows I will rename your FUNC_RES and OUTPUT type parameters to F and O respectively, in order to conform with the more common naming convention.


The problem is that there is no good inference site for the second generic type parameter on your tryGate<R, O>(...) function. It accepts a function of type () => Promise<R> and you expect the compiler to infer both R and O from that. But O is not mentioned at all.

The only way that would even be possible is if the compiler deferred inferring type arguments until it saw how you use the value returned. But that's not how TypeScript works. Type arguments for generic functions must be specified (either manually or inferred) immediately upon calling the function.

And that makes sense, because what would you expect to happen here?

const x = tryGate(async () => await readdir("...", "utf8"));

What should the O type parameter have been inferred as? It seems like you want the compiler to wait here until it sees what you do with x. But of course you could call x.onPass() multiple times, like this:

x.onPass(paths => paths.length); // O should be number here
x.onPass(paths => paths); // O should be string[] here

And then it seems like the compiler should tear its metaphorical hear out and run away screaming. Figuratively.

There's nothing good to be done with a single O here. The compiler needs to infer something, but there's no good inference site, so inference fails and it falls back to unknown.


Looking back to how x is above, you'd want O to stay generic after the call to tryGate() in order for that to work well. And that indicates that you have your O type parameter in the wrong scope. If you move it out of tryGate()'s generic call signature and down into the call signature of the returned onPass method, making that method generic as well, then things start behaving nicely:

function tryGate<R>(func: () => Promise<R>) { // only R here
    return {
        onPass<O>(onPass: (res: R) => O) { // O has been moved down here
            return {
                onThrow(onThrow: (catched: unknown) => O) {
                    return {
                        async exec() {
                            try {
                                const res = await func()
                                return onPass(res)
                            } catch (e: unknown) {
                                return onThrow(e)
                            }
                        },
                    }
                },
            }
        },
    }

And now when you call tryGate(), you get back something where O is still generic:

const x = tryGate(async () => await readdir("...", "utf8"));
/* const x: {
    onPass<O>(onPass: (res: string[]) => O): {
        onThrow(onThrow: (catched: unknown) => O): {
            exec(): Promise<O>;
        };
    };
} */

And so you can call its onPass() method twice and get two different generic instantiations:

const y1 = x.onPass(paths => paths.length);
/* const y1: {
    onThrow(onThrow: (catched: unknown) => number): {
        exec(): Promise<number>;
    };
} */

const y2 = x.onPass(paths => paths);
/* const y2: {
    onThrow(onThrow: (catched: unknown) => string[]): {
        exec(): Promise<string[]>;
    };
} */

And that means your whole chain from your example ends up as Promise<string[]> instead of Promise<unknown>.

const test = tryGate(async () => await readdir("...", "utf8"))
    .onPass((paths) => paths)
    .onThrow(() => [])
    .exec();
// const test: Promise<string[]>

Playground link to code

  • Related