How to create a generic type that resolves to the type of a nested property
Say I have a configuration type
type Config = {
parent: {
child: boolean
}
}
I would like something similar to
type ChildConfigType = NestedType<Config, 'parent.child'> // boolean
type ParentConfigType = NestedType<Config, 'parent'> // {child: boolean}
I have a basic implementation for non nested types
function getProp<
Type extends object,
Key extends keyof Type
>(key: Key): Type[Key] {
return {} as Type[Key]
}
getProp<Config>('parent')
I also have non nested helper types that work
type PropsOf<
Type extends object
> = keyof Type
type TypeOfProp<
Type extends object,
Key extends PropsOf<Type>
> = Type[Key]
function getPropWithHelpers<
Type extends object,
Key extends PropsOf<Type>
>(key: Key): TypeOfProp<Type, Key> {
return {} as TypeOfProp<Type, Key>
}
I also have a NestedPropsOf helper that resolves to a flattened union of the nested properties
type NestedPropsOf<Type extends object> =
{
[key in keyof Type & (string | number)]: Type[key] extends object
? `${key}` | `${key}.${NestedPropsOf<Type[key]>}`
: `${key}`
}[keyof Type & (string | number)]
type NestedConfigProps = NestedPropsOf<Config> // 'parent' | 'parent.child'
And I am trying to implement a function with the signature
function getNestedProp<
Type extends object,
Key extends NestedPropsOf<Type>
>(key: Key): NestedTypeOfProp<Type, Key> {
return {} as NestedTypeOfProp<Type, Key>
}
getNestedProp<Config>('parent.child')
I am starting to believe this is not possible because the types can’t directly manipulate strings so I cannot remove the first segment of the path.
>Solution :
It is actually possible to manipulate strings using template literals and infer keyword.
Utilities:
Dot is used to concatenate two strings with .:
type Dot<T extends string, U extends string> = "" extends U ? T : `${T}.${U}`;
StopFields is the type on which we should stop going further in the types; in our case, those are primitives:
type StopFields = string | number | boolean | symbol;
PathToFields returns all possible paths to properties of the passed type using mapped types. It ignores arrays, which can be adjusted.
type PathsToFields<T> = T extends StopFields
? ""
: {
[K in Extract<keyof T, string>]: NonNullable<T[K]> extends Array<any>
? never
: K | Dot<K, PathsToFields<T[K]>>;
}[Extract<keyof T, string>];
Implementation:
type NestedType<T, Path extends PathsToFields<T>> = Path extends keyof T
? T[Path]
: Path extends `${infer Property extends keyof T & string}.${infer SubField}`
? SubField extends PathsToFields<T[Property]>
? NestedType<T[Property], SubField>
: never
: never;
NestedType expects a type and its paths to fields. It first checks if the path is the property of the object, in that case, returns the type under that property, otherwise splits the path with . where the left part should be the key of the passed object and the part after the . is the remaining path. If all conditions are true, we invoice NestedType for the property that we got from splitting the path and passing the remaining path.
Usage:
type ChildConfigType = NestedType<Config, "parent.child">; // boolean
type ParentConfigType = NestedType<Config, "parent">; // {child: boolean}