javascripttypescript

Arrow functions with untyped parameters resolve to undefined when inferring their types


I'm trying to create a value resolver for functions and raw values and this function works for 99% of the cases I'm testing but I'm getting an issue when resolving an untyped arrow function.

declare function getValue<TValue>(value: TValue, args?: TValue extends ((...args: infer TArgs) => any) ? TArgs : undefined): TValue | undefined;

const value1 = getValue((c: number) => 23, [23]); // OK

const func1 = (c) => 23;
const value2 = getValue(func1, [23]); // OK

const value3 = getValue((c) => 23, [23]);
                                   ^^^^-ERROR

Playground code

Error:

Argument of type 'number[]' is not assignable to parameter of type 'undefined'

What is the issue here and can it be fixed without losing type safety?


Solution

  • Your const func1 = (c) => 23; is not really "OK"; TypeScript infers c as any (which is a compiler error for --strict), so of course you can call getValue(func1, [23]). You could also call getValue(func1, ["oopsie"]) or getValue(func1, [null]). So I'm not going to pay attention to this case, since it has very little to do with getValue().


    If you need to be able to call getValue(v, a) and sometimes infer a from the type of v, but also sometimes infer the contextual type of the callback parameter of v from the type of a, then the conditional type in your code won't work for you. That version assumes that v's type is known and a is computed from it.

    I think since you're trying to support two fundamentally different ways of calling getValue(), you'll be happier if you implement it as an overloaded function. That way each call signature can be fairly straightforward. For example:

    declare function getValue<const A extends any[], R>(v: (...a: A) => R, a: A): R;
    declare function getValue<V>(v: V): V;
    

    If you call getValue(v, a) with two arguments, where the first argument v is a function, then TypeScript selects the first call signature of getValue. This is generic in both the type A of the function's arguments (and I make it a const type parameter so TypeScript will care about the order and length of a), and the type R of the function's return type. This should give you inference you expect in both directions (either a from v, or the parameters of v from a).

    As an aside, I've decided to return R there instead of ((...a: A)=>R) | undefined because I think that's your underlying use case, where you call the function and return the result. But feel free to do whatever you want there; the question's not really about the return type anyway).

    On the other hand, if you call getValue(v) with one argument then the second call signature is chosen, and it just returns the same type as v.

    Let's test it:

    const r1 = getValue((x: string, y: number) => x.length > y, ["abc", 123]); // okay
    //    ^? const r1: boolean
    const r2 = getValue(
        (x: string, y: number) => x.length > y,
        [123, "abc"] // error!
        //~~, ~~~~~ bad arguments
    );
    

    In these cases the callback parameters are annotated, so TypeScript infers the type of A from it, and either accepts or rejects the a parameter.

    const r3 = getValue((x, y) => x.length > y, ["abc", 123]); // okay
    //    ^? const r3: boolean;
    const r4 = getValue(
        (x, y) => x.length > y, // error!
        //          ~~~~~~ no length on number
        [123, "abc"]
    );
    

    In these cases the callback parameters are not annotated, and, as you desired, TypeScript infers A from the type of a, and then either accepts or rejects the callback function depending on the contextual type for the callback parameters.

    const r5 = getValue("abc");
    //    ^? const r5: "abc";
    

    Finally, in this case, the call is not passing in a function at all, and so the second call signature is selected and there's only the type parameter V to infer.

    There might well be other edge cases here for you to worry about (e.g., if you call getValue(v) where v is a function, then the second call signature is selected. Is that what you want? Who knows) but the general approach of using an overload should hopefully work well enough when you call getValue(). Overloads have their own limitations (e.g., it's hard to infer from them, or call them in a generic way), so I often stay away from them in favor of single generic call signatures, unless there is a compelling reason to use them, such as, possibly, the sort of inference you're trying to achieve here.

    Playground link to code