typescript

Type inference with methods vs arrows


Here's some (toy) code to create an object from members (fields and methods), together with options that apply to those members:

interface FieldOptions {
    type: 'field'; // ... etc
}

interface MethodOptions {
    type: 'method';
}

type Options<T> = {
    [K in keyof T]?: T[K] extends Function ? MethodOptions : FieldOptions;
}

function createModel<T>(members: T, options: Options<T>) {
    // do something with members and options
    return {} as T;
}

If members includes a method (or function), type inference fails (the types of x and foo are unknown):

const m1 = createModel({
    x: 2,
    foo() {
        return 1;
    }
},
{
    x: {type: 'field'},
    foo: {type: 'method'} // error - Type '"method"' is not assignable to type '"field"'
});

However, if members includes an arrow function, type inference works as expected:

const m2 = createModel({
    x: 2,
    foo: () => {
        return 1;
    }
},
{
    x: {type: 'field'},
    foo: {type: 'method'} // ok
});

Note that the --strictFunctionTypes flag (which leads to differences in how methods and functions are handled - see, for example, here) has no effect on the result.

Any suggestions on the cause of / how to fix this issue?

Link to playground


Solution

  • You have run into a current limitation of TypeScript's inference abilities, as described in microsoft/TypeScript#47599. TypeScript sometimes has trouble when it needs to infer both generic type arguments and contextual types. The inference algorithm proceeds in a particular order, and sometimes this order is not compatible with the order of operations needed for successful inference. There have been improvements to this algorithm over time, but there will likely always be some situations where things break that "shouldn't".

    In the case of a method like { foo() { ⋯ } }, TypeScript wants to know the type of the this context inside the method body, since that in general can affect the return type of the method and thus the type of the call signature. Now, in fact, { foo() { return 1 } } doesn't actually depend on this at all. (It's not like { foo() { return this.xxx } }) But TypeScript doesn't currently inspect the body to see if this can be ignored. Instead it decides it should wait until it knows enough about this to proceed.

    What ends up happening in your example is therefore TypeScript decides that the type of the members argument cannot immediately be determined, and therefore it defers inference there. It instead looks at the options argument . And while it can infer the type of that easily, it cannot use that type to properly infer T, because Options<T> is a mapped type where each property at key K is a conditional type that doesn't preserve enough information about T[K]. So it ends up inferring T as something like {x: unknown, foo: unknown} and you have problems where Options<T> is all FieldOptions and no MethodOptions.

    On the other hand, an arrow function like { foo: () => { return 1 } } has no this context. The body can be analyzed without needing contextual type information. So it is immediately seen as type { foo(): 1 } and T is inferred from the members argument.


    That's what's happening. In order to fix it, you must change the types so that TypeScript does not attempt to infer T from options at all. It must be, um, "encouraged" to continue to analyze members before inferring T. One easy-ish way to do that is to use the NoInfer<T> utility type:

    function createModel<T>(members: T, options: NoInfer<Options<T>>) {
        return {} as T;
    }
    

    Now when you call it:

    const m1 = createModel({
        x: 2,
        foo() {
            return 1;
        }
    },
        {
            x: { type: 'field' },
            foo: { type: 'method' }
        });
    

    It succeeds because T is inferred from members as { x: number; foo(): 1; }, just like the case with the arrow function.


    If NoInfer<T> hadn't worked, then the workarounds would involve more explicit breaking of the control flow into pieces, such as a builder or curried function, where the first call infers T and then a subsequent call uses members.

    Or, sometimes the only thing you can do is force callers to be more explicit with types, either by manually instantiating the generic type argument

    const m1a = createModel<{ x: number, foo(): 1 }>({ // here's T
        x: 2,
        foo() {
            return 1;
        }
    },
        {
            x: { type: 'field' },
            foo: { type: 'method' }
        });
    

    or by manually specifying the this context via a this parameter type:

    const m1b = createModel({
        x: 2,
        foo(this: any) { // I don't care what `this` is
            return 1;
        }
    },
        {
            x: { type: 'field' },
            foo: { type: 'method' }
        });
    

    Which is basically cutting the knot instead of untying it.

    Playground link to code