Consider the function f, which is defined as follows:
function f<T extends Fields = Fields>(props: Props<T>) {
return null;
}
In this function, T represents a generic type that extends Fields, with Fields defined as:
export type Fields = { [key: string]: unknown };
Furthermore, the Props interface is defined as:
export interface Props<T extends Fields = Fields> {
fields: Config<T>;
onSubmit?: (values: Values<T>) => void;
}
Here, Props accepts a generic type T that extends Fields, and it consists of two properties: fields and onSubmit. The fields property is of type Config<T>, and the onSubmit property is an optional function that takes values of type Values<T> and returns void.
To provide more context, Config and Values are defined as follows:
type BaseProps<T> = {
initialValue: T;
hidden?: boolean;
};
export interface TextInput extends BaseProps<string>, TextInputProps {
type: 'text';
}
export interface Checkbox extends BaseProps<boolean> {
type: 'checkbox';
}
type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Values<T extends Fields> = {
[K in keyof T]: Config<T>[K]['initialValue'];
};
Here, Config<T> represents a mapped type where each key in T is mapped to either a TextInput or Checkbox. On the other hand, Values<T> represents a mapped type where each key in T is mapped to the initial value of the corresponding field in Config<T>.
In summary, the function f expects props of type Props, which contains information about the form fields (fields) and an optional submit function (onSubmit). The fields property is defined using a mapped type (Config<T>), and the initial values of these fields are extracted using another mapped type (Values<T>).
The core question here is whether there's a method for Values to automatically infer the correct type. Presently, the values.age type is inferred as string | boolean
. This ambiguity stems from the use of the 'or' operator within Config, which allows for either a TextInput or Checkbox.
The concern is not merely about the technical aspect but also about the design implications. Is there a structural or architectural adjustment that can lead to more precise type inference? Or is this ambiguity inherent in the design and therefore acceptable?
In essence, we're exploring whether there's a way to refine the type inference mechanism to accurately determine the type of values.age.
f({
fields: {
name: {
type: 'text',
initialValue: 'John Doe',
},
age: {
initialValue: true,
type: 'checkbox',
},
},
onSubmit: (values) => {
console.log(values.age);
},
});
I'll provide the entire content here for convenient copying.
type BaseProps<T> = {
initialValue: T;
hidden?: boolean;
};
export interface TextInput extends BaseProps<string> {
type: 'text';
}
export interface Checkbox extends BaseProps<boolean> {
type: 'checkbox';
}
type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Fields = { [key: string]: unknown };
export type Values<T extends Fields> = {
[K in keyof T]: Config<T>[K]['initialValue'];
};
export interface Props<T extends Fields = Fields> {
fields: Config<T>;
onSubmit?: (values: Values<T>) => void;
}
function f<T extends Fields = Fields>(props: Props<T>) {
return null;
}
f({
fields: {
name: {
type: 'text',
initialValue: 'John Doe',
},
age: {
initialValue: true,
type: 'checkbox',
},
},
onSubmit: (values) => {
console.log(values.age);
},
});
I experimented with several approaches to infer that, but unfortunately, none of them proved successful.
Since your goal is to actually restrict fields
to an object whose properties are some element of the union TextInput | Checkbox
, as keyed by the type
property, we should make a mapping from that property to the allowed member of the union:
type AllowableProps = TextInput | Checkbox;
type TypeMap = { [T in AllowableProps as T['type']]: T };
/* type TypeMap = {
text: TextInput;
checkbox: Checkbox;
} */
(Note that you can add members to AllowableProps
as needed.)
Now we can use the mapping make things generic in a object type T
which connects field names to the type
of input, like {name: "text", age: "checkbox"}
:
type Config<T extends Record<keyof T, keyof TypeMap>> =
{ [K in keyof T]: { type: T[K] } & TypeMap[T[K]] };
interface Props<T extends Record<keyof T, keyof TypeMap>> {
fields: Config<T>;
onSubmit?: (values: { [K in keyof T]: TypeMap[T[K]]['initialValue'] }) => void;
}
function f<T extends Record<keyof T, keyof TypeMap>>(props: Props<T>) {
return null;
}
We've constrained T
to a type whose property values are keys of TypeMap
.
Then Config<T>
maps over the properties of T
into the appropriate fields
property type; for each key K
in keyof T
, T[K]
is the relevant key of TypeMap
. So TypeMap[T[K]]
is either TextInput
or Checkbox
depending on K
. And intersection with {type: T[K]}
lets the compiler infer T
from fields
, since it knows it can just inspect the type
property.
Then the type of onSubmit
is a function whose argument is of the mapped type { [K in keyof T]: TypeMap[T[K]]['initialValue'] }
, which essentially recovers the underlying data type by indexing into either TextInput
or Checkbox
with the initialValue
key.
Let's test it out:
f({
fields: {
name: {
type: 'text',
initialValue: 'John Doe',
},
age: {
initialValue: true,
type: 'checkbox',
},
},
onSubmit: (values) => {
console.log(values.age);
// ^? (property) age: boolean
},
});
Looks good. If you inspect the call the f
with IntelliSense, you'll see that T
is inferred as {name: 'text', age: 'checkbox'}
, as desired. That means values
is contextually typed as {name: string, age: boolean}
and you get the behavior you're looking for.