Search code examples
typescriptkey

TypeScript Type for Getting All Nested Key Paths of an Object (Including Arrays and Their Paths)


I'd like to find a way to have a type in TypeScript that will take in some object type and get all the nested key paths, including any properties that might be arrays and indexing into those and getting those properties on any objects inside those arrays. I don't however, want any of those nested arrays' built-in properties (like "push", "pop", etc. to appear.) I have seen a number of solutions that do this and it's not exactly what I am looking for. If I have this object:

const person = {
    name: 'Jim',
    address: {
        street: '1234 Some Street',
        city: 'Some City',
        state: 'Some State'
    },
    hobbies: [
       { 
           name: 'Sports'
       }
    ]
}

type RESULT = DeepKeys<typeof person>

, I'd expect these key paths to be name | address | address.street | address.city | address.state | hobbies | hobbies.${number} | hobbies[${number}] | hobbies.${number}.name | hobbies[${number}].name

Is this possible to do this without getting all the array methods and any extra keys in the union that aren't valid?

I have tried a number of types I have found online but none have satisfied this exactly.


Solution

  • Okay, so I've definitely written this sort of thing before, see Typescript: deep keyof of a nested object and other similar questions, but deeply recursive types like this always have bizarre edge cases, and different people seem to have quite different opinions about what the right behavior should be in those cases. So in what follows I'm targeting the particular example from the question. For other cases, it might not do what readers expect. And fixing such deeply recursive types can sometimes involve fairly radical refactoring. Beware.


    The basic approach looks like this:

    type DeepKeys<T> = T extends object ? (
        { [K in (string | number) & keyof T]:
            `${(
                `.${K}` | (`${K}` extends `${number}` ? `[${K}]` : never)
            )}${"" | DeepKeys<T[K]>}` }[
        (string | number) & keyof T]
    ) : never
    

    This is a distributive object type (as coined in microsoft/TypeScript#47109) of the form {[K in KK]: F<K>}[KK], a mapped type into which I immediately index to get a union of F<K> for all K in the union KK. In this case, KK is (string | number) & keyof T, meaning all the serializable (non-symbol) keys of T. And F<K> is a big template literal thing of the form

    `${(`.${K}` | (`${K}` extends `${number}` ? `[${K}]` : never))}${"" | DeepKeys<T[K]>}`
    

    There's two parts to it concatenated together. The left part is (`.${K}` | (`${K}` extends `${number}` ? `[${K}]` : never)). It always includes the key prefixed with a dot .. If K is numeric-like, then it also includes the key in square brackets []. The right part is "" | DeepKeys<T[K]>}. It includes the empty string (so the dotted/bracketed key is included with no suffix) and DeepKeys<T[K]> (so it recursively adds the keys of the current property as a suffix).

    This recursive thing gets us most of the way there. Let's try it on your example:

    type Result = DeepKeys<typeof person>
    /* type Result = ".name" | ".address" | ".address.street" | ".address.city" |
     ".address.state" | ".hobbies" | `.hobbies.${number}` | `.hobbies[${number}]` |
     `.hobbies.${number}.name` | `.hobbies[${number}].name` | ".hobbies.length" |
     ".hobbies.toString" | ".hobbies.toLocaleString" | ".hobbies.pop" | ".hobbies.push" | 
     ".hobbies.concat" | ".hobbies.join" | ".hobbies.reverse" | ".hobbies.shift" |
     ".hobbies.slice" | ".hobbies.sort" | ".hobbies.splice" | ".hobbies.unshift" | 
     ".hobbies.indexOf" | ".hobbies.lastIndexOf" | ".hobbies.every" | ".hobbies.some" |
     ".hobbies.forEach" | ".hobbies.map" | ".hobbies.filter" | ".hobbies.reduce" |
     ".hobbies.reduceRight" | ".hobbies.find" | ".hobbies.findIndex" | ".hobbies.fill" | 
     ".hobbies.copyWithin" | ".hobbies.entries" | ".hobbies.keys" | 
     ".hobbies.values" | ".hobbies.includes" */
    

    So the two problems here are:

    • Arrays include all the array property names, and
    • All keys are prefixed with a dot.

    Let's fix those. First, we can wrap T in a FixArr<T> which checks if T is an array, and if so, omits every property present on all arrays, except for number (because we want number in our output). Like this:

    type FixArr<T> = T extends readonly any[] ? Omit<T, Exclude<keyof any[], number>> : T;
    

    Next, we can write a type to drop any initial dot from a string, and put that as the output.

    type DropInitDot<T> = T extends `.${infer U}` ? U : T;
    

    So let's rename the old DeepKeys as _DeepKeys, a helper type, and use FixArr<T[K]> instead of T[K]:

    type _DeepKeys<T> = T extends object ? (
        { [K in (string | number) & keyof T]:
            `${(
                `.${K}` | (`${K}` extends `${number}` ? `[${K}]` : never)
            )}${"" | _DeepKeys<FixArr<T[K]>>}` }[
        (string | number) & keyof T]
    ) : never
    

    And then DeepKeys is

    type DeepKeys<T> = DropInitDot<_DeepKeys<FixArr<T>>>
    

    so we make sure that we fix T if it's an array, perform _DeepKeys on the result, and then DropInitDot it. That gives us

    type Result = DeepKeys<typeof person>
    /* type Result = "name" | "address" | "hobbies" | 
    "address.street" | "address.city" | "address.state" | 
    `hobbies.${ number } ` | `hobbies[${ number }]` | 
    `hobbies.${ number }.name` | `hobbies[${ number }].name` */
    

    as desired.

    Playground link to code