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
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.