Search code examples
typescripttypescript-genericstypescript-types

Map typescript array elements to keyof object, recursively


I'd like to express a path lookup for objects, with a type that ensures that the path is correctly typed based on an object type.

It's probably best to explain with an example. Given this, or any similar data:

const obj = {
    name: 'Test',
    city: { name: 'London' },
    tags: [
        { name: 'tag1' }, { name: 'tag2' }
    ]
}

I'd like to have this kind of type:

type FieldPath<typeof obj, [maybe other parameters]>

That should ensure these constraints:

// keyof access
const path1 : FieldPath<typeof obj> = ['name']

// Doesn't exist
const path2 : FieldPath<typeof obj> = ['nope'] // Error: Type 'nope' is not assignable to...

// Nested object access
const path3 : FieldPath<typeof obj> = ['city', 'name']

// Doesn't exist (on city)
const path4 : FieldPath<typeof obj> = ['city', 'nope'] // Error: Type 'nope' is not assignable to...

// Array access
const path5 : FieldPath<typeof obj> = ['tags', 1]

// Invalid keyof access
const path6 : FieldPath<typeof obj> = ['tags', 'nope'] // Error: Type 'nope' is not assignable to...

// Keep going recursively
const path7 : FieldPath<typeof obj> = ['tags', 1, 'name']

// With failures
const path8 : FieldPath<typeof obj> = ['tags', 1, 'nope'] // Error: Type 'nope' is not...

Is this possible?


Solution

  • Unfortunately, it is not possible at the moment since the compiler cannot handle such cases. For example, if the valid options are these: ['a', 'subA'] | ['b', 'subB'] you will still be able to write something like this: ['a', 'b']. There is an alternative approach with the path being in dot notation (a.subA). If that fits your case please consider using the following solution.

    To actually get the paths in dot notation we will need a type to generate those:

    // If there SubPath then return 'BasePath.SubPath', otherwise return BasePath'
    type Dot<BasePath extends string, SubPath extends string> = '' extends SubPath
      ? BasePath
      : `${BasePath}.${SubPath}`;
    

    We will also add stop conditions, for example on which types we should stop. In our case, we want to stop on primitive types number | string | boolean.

    Therefore we declare the following type:

    type DefaultTargetFields = number | string | boolean;
    
    

    The last part is the traverse function:

    type PathsToFields<Obj, TargetFields = DefaultTargetFields> = Obj extends TargetFields
      ? ''
      : {
          [K in keyof Obj & string]: Dot<K, PathsToFields<Obj[K], TargetFields>>;
        }[keyof Obj & string];
    

    It takes two arguments, the object that we are traversing and the types that we are looking for (defaulted to the DefaultTargetFields).

    We first check if the Obj is the type that we are looking for (Obj extends TargetFields). If that's true we return an empty string indicating that the path ends here. Otherwise we loop through the string keys of Obj and call Dot type with BasePath = K and the SubPath = PathsToFields<Obj[K], TargetFields>>. PathsToFields<Obj[K], TargetFields>> is recursively calling to sub-properties of the Obj

    Usage:

    // type B = "name" | "city.name" | "tags.length" | "tags.0.name" | "tags.1.name"
    type B = PathsToFields<typeof obj>;
    

    Link to playground