Search code examples
typescriptfunctiontype-inferencehigher-order-functions

Type inference not working for higher-order functions in TypeScript


TypeScript does not infer properly the generic type of a function that accepts a function that exposes as its argument a function that when called should be provided with an argument that is generic.

const notWorking = <Value>(callback: (emitValue: (value: Value) => void) => void): void => {
  //...
}

notWorking((emitValue) => {
  emitValue(123);
});

When called, the notWorking function here does not infer anything, instead it treats the generic argument as unknown, even though the argument passed to the emitValue function is a number.

I tried assigning a default value to the Value generic argument with never thinking that it would at least error out and force me to provide an explicit generic argument to the function, and it does work when writing the code below.

const notWorking = <Value = never>(callback: (emitValue: (value: Value) => void) => void): void => {
  //...
}

notWorking<number>((emitValue) => {
  emitValue(123);
});

But I'm now forced to provide the generic argument, and I would like to know if there was a way to kind of like make TypeScript a little more smart about the inference of those kinds of higher-order functions? Or is it a limitation of the language itself?

Using a generic in the function passed as argument is not an option for me.


Solution

  • TypeScript's type inference algorithm does not attempt to infer function parameter types from the types of arguments passed to the function at call sites. So emitValue's parameter type cannot be inferred given notWorking((emitValue) => emitValue(123)), similarly to how the type of x cannot be inferred in the following:

    function foo(x) {}
    // later
    foo("abc"); 
    

    TypeScript can only infer the types of unannotated parameters via contextual typing, but there is no context for the parameter you're looking for. TypeScript can contextually infer that emitValue must be of type (value: Value) => void for some generic Value, but there is no context at that declaration site for what Value might be. Only elsewhere, in the call to emitValue(123) is there any hint about what might be expected, but it's not in the right place for inference to happen.

    Generally speaking if you want a function parameter to have a type, you should annotate that parameter yourself, even if this is more verbose than you'd like:

    notWorking((emitValue: (value: number) => void) => emitValue(123)); // okay
    

    Type inference of values from later usage does exist in some languages, notably Flow. See microsoft/TypeScript#15114 for the canonical discussion for the lack of this as a feature of TypeScript and the reasoning behind it. Also see issues linked from that issue.

    Ryan Cavanaugh, the development lead for the TypeScript team at Microsoft, said:

    Current inference is very straightforward: types almost always come from initializers or contextual types. Both of those are static one-pass things that you can follow back as a human. Trying to do inference from call sites looks neat in simple examples but can make it very hard to trace errors to their actual sources, and is unscalable to large programs.

    In another comment, he gives a concrete example for what goes wrong when inferring from usage:

    Type guards / flow analysis are straightforward (so to speak...) because they're "top-down" - given a statement in a function, it's relatively easy to determine which control paths it's reachable from, because JavaScript doesn't have control flow structures that can go "up" (e.g. COMEFROM). This is very different from a function call - you can be in a function on line 1500 and the first call might be on line 4000. Or maybe you're indirectly called via some callback, etc..

    The dream of inference from function calls is really not clear as some people would imply. Here's an example in Flow:

    swapNumberString(n: string): number; declare function
    swapNumberString(n: number): string;
    
    // Pass-through function function subs(s) {   return
    swapNumberString(s); }
    
    // This stops being an error if you comment // out the call in g()
    below. ?? const s: string = subs(12);
    
    function g() { subs(""); } 
    

    This is "spooky action at a distance" at its most maximal. You can have the call to subs in g, or the call in the s initializer, but not both. Huh? And why does the wrapper function change the behavior of this at all (replacing subs with swapNumberString makes the errors go away), if it's all inferential magic?

    Realistically there are two cases that usually happen if you use inference from call sites / data flow analysis:

    • Your file typechecks
    • You get an error in a correctly-implemented function body due to a bad call

    If your file typechecks, cool, no work required.

    In the other case, the first thing you do to diagnose the problem is... drumroll... add a parameter type annotation so you can figure out where the bad call is coming from. If there's indirection, you'll probably have to do this in layers. You'll end up with a file full of parameter type annotations, which is good since you'll need them anyway for cross-file typechecks. So it's great for a single-file playground demo but for "real" software development, you'll end up with approximately the same number of parameter type annotations as if you used a more local inference algorithm.

    Finally, in a comment in a linked issue, microsoft/TypeScript#15196, a contributor to the repo said

    I have a PR to add a quick fix to infer the type as number from such cases in #14786.

    Inferring from call sites is something we have decided not to pursue for the TS compiler since its inception, and there are multiple reasons. For example, what if the function is not called? What if the use is in another compilation? What if the call is the error, and not the function declaration? What if the calls in your tests, are not exhaustive?

    Moreover, this approach really works for a small snippets of code but does not scale specially with IDE scenarios and you want to know what changing one name mean for the whole compilations. Systems we have seen that take this approach either limit the inference to one file to make it more manageable, or do not care about accuracy.

    We believe that type annotations serve better when added to declarations rather than non-local use sites for inference; they help readability and allow tools to correctly check for the user-intended behaviors. Using noImplicitAny to mark locations TypeScript could not figure out types in other locations helps ensure the experience stays consistent and prescriptive.

    So it's almost certainly the case that such inference will never happen in TypeScript. The first line in that last comment refers to microsoft/TypeScript#14786, a "quick fix" which a compliant IDE will show you when you run into implicit any errors on function parameters. So in some cases, you can get your IDE to do the type annotation for you. This is essentially a shallow implementation of the sort of inference you want, but it only happens when you ask for it explicitly, in an IDE, so it can't snowball into a cascading mess of global inferences.

    The particular quick fix doesn't help in your example, since the failure of inference results in a generic type argument falling back to its default, and there's no implicit any to speak of. But in the other example

    function foo(x) { } // error!
    //           ~ <-- Parameter 'x' implicitly has an 'any' type.
    foo("abc");
    

    if you ask for editor support for x, you'll see a suggestion like "Quick Fix/💡 Infer parameter types from usage" which offers to change the code to be

    function foo(x: string) { }
    foo("abc");
    

    which makes things compile without error.

    In any case, we can safely say that TypeScript's support for function parameters inference from usage is extremely limited, and unlikely to be improved in the future.

    Playground link to code