Search code examples
typescriptgeneric-type-argument

TypeScript: How to use return type as T from argument callback?


Goal:

I expected it to use the return value from the second callback.

Problem:

it seems to point to an unknown type, how can I fix it?

function Test1<T>( msg, fn:(arg:T)=>void, fnTake:(any)=>T )
function Test1( msg, fn, fnTake ) {}

Test1( 'test', (arg) =>{ arg.ZZ }, m => { ZZ:123 } );
//             the 'arg' is unknown

code


Solution

  • This is a design limitation of TypeScript. There are a number of GitHub issues filed about this; microsoft/TypeScript#25826 might be a good one to focus on.

    When you call Test1, the compiler needs to both infer the generic type parameter T, as well as the contextual type for the callback argument arg. It does this in several "passes", and unfortunately it does these in a bad order for your case.

    I don't have the full picture for what happens specifically, but a sketch looks like this. When you call,

    Test1('test', (arg) => { arg.ZZ }, m => ({ ZZ: 123 }));
    

    the compiler wants both to infer the type of arg from T and the type of T from arg, and it can't do that, so it defers inference until later. It moves on to the second callback. This one also has an unannotated argument, so it defers inference here too. On the next pass through it has to try to infer the type parameter T, but there's still no information for it to use when it examines the first callback. It has to pick something now, so it fails and picks unknown. And you get your error. 😢


    It's possible that TypeScript's inference algorithm should be made better to deal with things like this, but it could be a huge breaking change. According to a comment in that GitHub issue

    After a fairly long discussion of options here, we don't have any ideas for how to fix this without breaking other scenarios. Full unification is of course a "solution" but that's basically a complete ground-up rewrite, so not really in the cards for the time being.

    The "full unification" mentioned is the topic of microsoft/TypeScript#30134, but there don't seem to be any immediate plans to do anything here.


    So, until and unless this is fixed, there are workarounds. The easiest workaround is to annotate the parameter to the second callback:

    Test1('test', arg => { arg.ZZ }, (m: any) => ({ ZZ: 123 })) // T is {ZZ: number}
    

    This defers inference for arg => ..., and then does not defer for (m: any) => .... It knows the types there, and sees that the return type is {ZZ: number}. Now it can infer T properly from this, and on the second pass, arg is known to have type {ZZ: number} and all is well.

    Similarly you can specify types to help inference along, either by annotating arg or specifying T when you call Test1:

    Test1('test', (arg: { ZZ: number }) => { arg.ZZ }, m => ({ ZZ: 123 })) // T is {ZZ: number}
    Test1<{ ZZ: number }>('test', arg => { arg.ZZ }, m => ({ ZZ: 123 })) // obvs
    

    Another approach is to switch the order of your callbacks, since inference tends to proceed from left to right:

    declare function Test2<T>(msg: string, fnTake: (arg: any) => T, fn: (arg: T) => void): void;
    
    Test2('test', m => ({ ZZ: 123 }), arg => { arg.ZZ }); // T is {ZZ: number}
    

    I think the same deferring of inference happens on the first pass, but now on the second pass the compiler can examine m => ... and use the contextual type of any for m and then infer the return type of {ZZ: number}, and now it has an inference candidate for T, and everything works when it gets to the arg => ...callback.


    Okay, hope that makes sense and gives so some direction. Good luck!

    Playground link to code