I'm trying to type a createFieldGroup
function that takes a Fields
object:
declare function createFieldGroup<T>(fields: Fields<T>): T
type Fields<T> = {
[K in keyof T]: FieldSpec<T[K]>
}
type FieldSpec<T, U extends boolean = boolean> = {
value: T;
asString?: U;
validator?: (v: T) => void;
}
Provided validator
functions infer their parameters' type from the corresponding value
property's type. That part works with the previous code. However, createFieldGroup
's return value should be an object with the same keys as fields
, but whose types are either the type of the corresponding value
property, or string
if asString
is set to true
.
Example:
const x = createFieldGroup({
// ^? const x: { foo: string, bar: number }
foo: {
value: true,
asString: true,
validator(v) {},
// ^? (parameter) v: boolean
},
bar: {
value: 0,
asString: false,
validator(v) {},
// ^? (parameter) v: number
}
})
But I haven't been able to extract each property's asString
type to properly type the return type.
I thought of inferring the createFieldGroup
's type parameter T
as Record<string, { value: unknown, asString: boolean }>
by refactoring FieldSpec
as:
type FieldSpec<T extends { value: unknown, asString: boolean }> = {
value: T['value'];
asString: T['asString'];
validator(v: T['value']): void;
}
but inference doesn't seems to work with that approach.
Alternatively, I tried having two type parameters on createFieldGroup
on which to respectively infer the types of value
and asString
properties:
declare function createFieldGroup<
T extends Record<string, unknown>,
U extends Record<keyof T, boolean>
>(fields: Fields<T,U>): {
[K in keyof T]: U[K] extends true ? string : T[K]
}
type Fields<
T extends Record<string, unknown>,
U extends Record<keyof T, boolean>,
> = {
[K in keyof T & keyof U]: FieldSpec<T[K], U[K]>
}
This makes the previous example work, but if asString
is not included in one object, U
is not properly inferred.
I'd appreciate any help/insights on how to achieve the desired behavior.
TypeScript unfortunately cannot always infer both generic type arguments and the contextual type of callback parameters. The inference algorithm is heuristic in nature and proceeds in a particular order. If that order happens not to infer the types correctly then it will fail, even if a human being could easily see an order that would succeed. Perhaps if TypeScript used a full unification algorithm as described in microsoft/TypeScript#30134 then this wouldn't be an issue, but that would require an extensive overhaul of the type checker, and is exceedingly unlikely to happen (the current algorithm has some advantages over full unification when it comes to inference from partially written code).
The heuristic inference algorithm has been steadily improving over time to handle more cases, such as microsoft/TypeScript#48538 enabling left-to-right inference from within a single object literal function argument in much the same way that left-to-right inference from multiple function arguments works. But there are likely always going to be gaps. So while there are open feature requests like microsoft/TypeScript#47599 to improve simultaneous generic and contextual inference, it is safe to say this this is and will always be a design limitation of TypeScript.
Maybe some future version of TypeScript will allow this for your use case, but for now you have to give up on either the generic inference or the contextual callback parameter inference.
In your example you're trying to either infer a generic object type from indexes into the type, which isn't currently supported (see microsoft/TypeScript#51612), or you're trying to infer a second generic type argument from the first one without the full set of keys, which just fails because of the ordering.
My suggestion here is to give up on contextual callback parameter inference, and possibly write the code this way, which looks similar to (or maybe the same as) one of your attempts:
declare function createFieldGroup<T extends Record<string, FieldSpec<any, boolean>>>
(fields: T & { [K in keyof T]: { validator?: (v: T[K]["value"]) => void } }):
{ [K in keyof T]: T[K]["asString"] extends true ? string : T[K]["value"] }
Here T
will be inferred directly as the type of the fields
argument, and then the intersection will cause T
to be checked against the mapped type so that validator
's callback parameter will be rejected if it's not compatible with the value
for that property:
createFieldGroup({
foo: {
value: true,
asString: true,
validator(v: boolean) { },
},
bar: {
value: 0,
validator(v: string) { }, // error! string is not number
}
});
And then the return type of createFieldGroup
uses both the asString
and value
types of the properties of T
to determine whether each property should stay the same as value
or change to string
.
You can see that it works:
const x = createFieldGroup({
foo: {
value: true,
asString: true,
validator(v: boolean) { },
},
bar: {
value: 0,
asString: false,
validator(v: number) { },
}
});
// const x: { foo: string; bar: number; }
and is unaffected by whether asString
is missing instead of false
:
const x = createFieldGroup({
foo: {
value: true,
asString: true,
validator(v: boolean) { },
},
bar: {
value: 0,
// asString: false,
validator(v: number) { },
}
});
// const x: { foo: string; bar: number; }