Let's start with the simple stuff. At some point, we will need to map from the string representation "text"
to the type string
and other types. In your playground, you used a conditional type for this. That works, but using a simple map makes it easier to understand and expandable (and the performance is better too ^^).
type WidgetToType = {
"text": string
"number": number
}
I also define a type FormElement
for the given data structure.
type FormElement = {
widget: string,
children?: readonly FormElement[],
prop?: string,
optional?: boolean
}
This will make it easier to access properties by indexing the generic type T
later. We can use the type as a constraint for T
to do things like T["prop"]
without needing to check if the property exists.
Now to the harder part. We know the resulting type is a flat object.
type MyForm = {
name: string;
age: number;
location?: string | undefined;
}
We also know that we can construct such object types with mapped types and that we need a union for mapping. So ideally we can create a helper type which will create a union from a given type which will look like this:
{
widget: "text";
prop: "name";
} | {
widget: "number";
prop: "age";
} | {
widget: "text";
prop: "location";
optional: true;
}
This union should contain all the needed information about each resulting property.
To create the union, I wrote the recursive type GetPropsDeeply
.
type GetPropsDeeply<T extends FormElement> =
| (T["prop"] extends string ? T : never)
| (T["children"] extends readonly any[]
? T["children"][number] extends infer U
? U extends FormElement
? GetPropsDeeply<U>
: never
: never
: never
)
GetPropsDeeply
starts at the root of the object type and begins building the union by performing two checks. First, it checks if T["prop"]
is set. If so, we can add T
to the union. Then it checks if T["children"]
is set. If that is the case, we convert the children
tuple to a union of children elements by indexing T["children"]
with number
. We can now use these union elements in the recursive call to GetPropsDeeply
.
Here is also a "hidden" TypeScript mechanic. By storing T["children"][number]
inside U
and checking if U extends FormElement
, we are distributing the union elements over GetPropsDeeply
.
So instead of having
GetPropsDeeply<E_1 | E_2 | E_3>
(where
E_N
is a union element), we
distribute over
GetPropsDeeply
resulting in
GetPropsDeeply<E_1> | GetPropsDeeply<E_2> | GetPropsDeeply<E_3>
.
Now to the last part.
type Infer<T extends FormElement> = (GetPropsDeeply<T> extends infer U ? ({
[K in U as K extends { optional: true }
? never
: K["prop" & keyof K] & string
]: WidgetToType[K["widget" & keyof K] & keyof WidgetToType]
} & {
[K in U as K extends { optional: true }
? K["prop" & keyof K] & string
: never
]?: WidgetToType[K["widget" & keyof K] & keyof WidgetToType]
}) : never) extends infer O ? { [K in keyof O]: O[K] } : never
We call GetPropsDeeply<T>
and store the result in U
. Since properties where optional
is true
are supposed to be optional, we need to construct two seperate mapped types where one is using the ?
notation and intersect them.
For the respective mapped type, we filter out the union elements where optional
is true
or false
respectively. To get the property name, we do K["prop"]
and we also use our mapping here to get the corresponding type of K["widget"]
.
Playground