In order to achieve this goal we need to create permutation of all allowed paths. For example:
type Structure = {
user: {
name: string,
surname: string
}
}
type BlackMagic<T>= T
// user.name | user.surname
type Result=BlackMagic<Structure>
Problem becomes more interesting with arrays and empty tuples.
Tuple, the array with explicit length, should be managed in this way:
type Structure = {
user: {
arr: [1, 2],
}
}
type BlackMagic<T> = T
// "user.arr" | "user.arr.0" | "user.arr.1"
type Result = BlackMagic<Structure>
Logic is straitforward. But how we can handle number[]
? There is no guarantee that index 1
exists.
I have decided to use user.arr.${number}
.
type Structure = {
user: {
arr: number[],
}
}
type BlackMagic<T> = T
// "user.arr" | `user.arr.${number}`
type Result = BlackMagic<Structure>
We still have 1 problem. Empty tuple. Array with zero elements - []
. Do we need to allow indexing at all? I don't know. I decided to use -1
.
type Structure = {
user: {
arr: [],
}
}
type BlackMagic<T> = T
// "user.arr" | "user.arr.-1"
type Result = BlackMagic<Structure>
I think the most important thing here is some convention. We can also use stringified `"never". I think it is up to OP how to handle it.
Since we know how we need to handle different cases we can start our implementation. Before we continue, we need to define several helpers.
type Values<T> = T[keyof T]
{
// 1 | "John"
type _ = Values<{ age: 1, name: 'John' }>
}
type IsNever<T> = [T] extends [never] ? true : false;
{
type _ = IsNever<never> // true
type __ = IsNever<true> // false
}
type IsTuple<T> =
(T extends Array<any> ?
(T['length'] extends number
? (number extends T['length']
? false
: true)
: true)
: false)
{
type _ = IsTuple<[1, 2]> // true
type __ = IsTuple<number[]> // false
type ___ = IsTuple<{ length: 2 }> // false
}
type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
type _ = IsEmptyTuple<[]> // true
type __ = IsEmptyTuple<[1]> // false
type ___ = IsEmptyTuple<number[]> // false
}
I think naming and tests are self explanatory. At least I want to believe :D
Now, when we have all set of our utils, we can define our main util:
/**
* If Cache is empty return Prop without dot,
* to avoid ".user"
*/
type HandleDot<
Cache extends string,
Prop extends string | number
> =
Cache extends ''
? `${Prop}`
: `${Cache}.${Prop}`
/**
* Simple iteration through object properties
*/
type HandleObject<Obj, Cache extends string> = {
[Prop in keyof Obj]:
// concat previous Cacha and Prop
| HandleDot<Cache, Prop & string>
// with next Cache and Prop
| Path<Obj[Prop], HandleDot<Cache, Prop & string>>
}[keyof Obj]
type Path<Obj, Cache extends string = ''> =
// if Obj is primitive
(Obj extends PropertyKey
// return Cache
? Cache
// if Obj is Array (can be array, tuple, empty tuple)
: (Obj extends Array<unknown>
// and is tuple
? (IsTuple<Obj> extends true
// and tuple is empty
? (IsEmptyTuple<Obj> extends true
// call recursively Path with `-1` as an allowed index
? Path<PropertyKey, HandleDot<Cache, -1>>
// if tuple is not empty we can handle it as regular object
: HandleObject<Obj, Cache>)
// if Obj is regular array call Path with union of all elements
: Path<Obj[number], HandleDot<Cache, number>>)
// if Obj is neither Array nor Tuple nor Primitive - treat is as object
: HandleObject<Obj, Cache>)
)
// "user" | "user.arr" | `user.arr.${number}`
type Test = Extract<Path<Structure>, string>
There is small issue. We should not return highest level props, like user
. We need paths with at least one dot.
There are two ways:
- extract all props without dots
- provide extra generic parameter for indexing the level.
Two options are easy to implement.
Obtain all props with dot (.)
:
type WithDot<T extends string> = T extends `${string}.${string}` ? T : never
While above util is readable and maintainable, second one is a bit harder. We need to provide extra generic parameter in both Path
and HandleObject
.
See this example taken from other question / article:
type KeysUnion<T, Cache extends string = '', Level extends any[] = []> =
T extends PropertyKey ? Cache : {
[P in keyof T]:
P extends string
? Cache extends ''
? KeysUnion<T[P], `${P}`, [...Level, 1]>
: Level['length'] extends 1 // if it is a higher level - proceed
? KeysUnion<T[P], `${Cache}.${P}`, [...Level, 1]>
: Level['length'] extends 2 // stop on second level
? Cache | KeysUnion<T[P], `${Cache}`, [...Level, 1]>
: never
: never
}[keyof T]
Honestly, I don't think it will be easy for any one to read this.
We need to implement one more thing. We need to obtain a value by computed path.
type Acc = Record<string, any>
type ReducerCallback<Accumulator extends Acc, El extends string> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends string,
Accumulator extends Acc = {}
> =
// Key destructure
Keys extends `${infer Prop}.${infer Rest}`
// call Reducer with callback, just like in JS
? Reducer<Rest, ReducerCallback<Accumulator, Prop>>
// this is the last part of path because no dot
: Keys extends `${infer Last}`
// call reducer with last part
? ReducerCallback<Accumulator, Last>
: never
{
type _ = Reducer<'user.arr', Structure> // []
type __ = Reducer<'user', Structure> // { arr: [] }
}
You can find more information about using Reduce
in my blog.
Whole code:
type Structure = {
user: {
tuple: [42],
emptyTuple: [],
array: { age: number }[]
}
}
type Values<T> = T[keyof T]
{
// 1 | "John"
type _ = Values<{ age: 1, name: 'John' }>
}
type IsNever<T> = [T] extends [never] ? true : false;
{
type _ = IsNever<never> // true
type __ = IsNever<true> // false
}
type IsTuple<T> =
(T extends Array<any> ?
(T['length'] extends number
? (number extends T['length']
? false
: true)
: true)
: false)
{
type _ = IsTuple<[1, 2]> // true
type __ = IsTuple<number[]> // false
type ___ = IsTuple<{ length: 2 }> // false
}
type IsEmptyTuple<T extends Array<any>> = T['length'] extends 0 ? true : false
{
type _ = IsEmptyTuple<[]> // true
type __ = IsEmptyTuple<[1]> // false
type ___ = IsEmptyTuple<number[]> // false
}
/**
* If Cache is empty return Prop without dot,
* to avoid ".user"
*/
type HandleDot<
Cache extends string,
Prop extends string | number
> =
Cache extends ''
? `${Prop}`
: `${Cache}.${Prop}`
/**
* Simple iteration through object properties
*/
type HandleObject<Obj, Cache extends string> = {
[Prop in keyof Obj]:
// concat previous Cacha and Prop
| HandleDot<Cache, Prop & string>
// with next Cache and Prop
| Path<Obj[Prop], HandleDot<Cache, Prop & string>>
}[keyof Obj]
type Path<Obj, Cache extends string = ''> =
(Obj extends PropertyKey
// return Cache
? Cache
// if Obj is Array (can be array, tuple, empty tuple)
: (Obj extends Array<unknown>
// and is tuple
? (IsTuple<Obj> extends true
// and tuple is empty
? (IsEmptyTuple<Obj> extends true
// call recursively Path with `-1` as an allowed index
? Path<PropertyKey, HandleDot<Cache, -1>>
// if tuple is not empty we can handle it as regular object
: HandleObject<Obj, Cache>)
// if Obj is regular array call Path with union of all elements
: Path<Obj[number], HandleDot<Cache, number>>)
// if Obj is neither Array nor Tuple nor Primitive - treat is as object
: HandleObject<Obj, Cache>)
)
type WithDot<T extends string> = T extends `${string}.${string}` ? T : never
// "user" | "user.arr" | `user.arr.${number}`
type Test = WithDot<Extract<Path<Structure>, string>>
type Acc = Record<string, any>
type ReducerCallback<Accumulator extends Acc, El extends string> =
El extends keyof Accumulator ? Accumulator[El] : El extends '-1' ? never : Accumulator
type Reducer<
Keys extends string,
Accumulator extends Acc = {}
> =
// Key destructure
Keys extends `${infer Prop}.${infer Rest}`
// call Reducer with callback, just like in JS
? Reducer<Rest, ReducerCallback<Accumulator, Prop>>
// this is the last part of path because no dot
: Keys extends `${infer Last}`
// call reducer with last part
? ReducerCallback<Accumulator, Last>
: never
{
type _ = Reducer<'user.arr', Structure> // []
type __ = Reducer<'user', Structure> // { arr: [] }
}
type BlackMagic<T> = T & {
[Prop in WithDot<Extract<Path<T>, string>>]: Reducer<Prop, T>
}
type Result = BlackMagic<Structure>
Playground
This implementation is worth considering