Moon
Moon

Reputation: 890

Typescript how to create a type getting all nested object key

In Typescript, I have a nested object variable:

Sorry the previous example confused people. The value is not same as its key. I will provide a better example.

const obj = {
    k1: {
        k1A: "k1A_A",
        k1B: "k2B_B",
        k1C: {
            k1C1: "k1C1_C",
            k1C2: "k1C2_D",
        },
    },
    k2: {
        k2A: {
            k2A1: "k2A1_E",
            k2A2: "k2A2_F",
        },
        k2B: {
            k2B1: "k2B1_G",
            k2B2: "k2B2_H",
        },
    },
    k3: {
        k3A: 'K3A_I'
    }
}

I have a function, which will lookup and ouput the deepest and nested object key path.

function getKeyPath(originalObj) {}

and the function runs like:

const keyPath = getKeyPath(obj)

and the output will be:

{
    k1A: ["k1"],
    k1B: ["k2B"],
    K1C1: ["k1", "k1C"],
    k1C2: ["k1", "k1C"],
    k2A1: ["k2", "k2A"],
    k2A2: ["k2", "k2A"],
    k2B1: ["k2", "k2B"],
    k2B2: ["k2", "k2B"],
    k3A: ["k3"],
}

with Typescript, what I current have set is:

function getKeyPath<T>(originalObj:T):{
    [x in string]: string[]
} {}

Is there a way, I can have a narrow type check? So instead of any string, I limit the return keys are from key of obj?

If I use type key = keyof typeof obj, I can only get the first level of key, but not the deeper one.

Upvotes: 1

Views: 6444

Answers (2)

Before, you read my answer please double check your obj shape. It looks like there is some inconsistency. k1B key has k2B value. I think it should be k1B, but I might be mistaken. Also, k3A key has uppercased K3A. Is this by design?

In order to transform obj interface to expected output, we need to recursively iterate through obj interface and accumulate each key into array of elements. Once we reach key which has primitive value, in our case k1A: "k1A" we need to exit the loop and return current key and a tuple of all keys we have passed through.

Consider this example:

type Iterate<Obj, Path extends any[] = []> =
    /**
     * If Obj is a string, it means that we are in the end of iteration
     * Hence we need to return an object where key is the last iterated value
     * and Path is a tuple of all keys wich were iterated
     */
    Obj extends string
    ? Record<Obj, Path>
    /**
     * Otherwise, we need to iterate through object properties
     */
    : {
        [Prop in keyof Obj]:
        /**
         * If Prop extends/equal current value
         */
        Prop extends Obj[Prop]
        /**
         * If Prop and Obj[Prop] are equal, no need
         * to push current Prop into Path tuple.
         * Call recursively Iterate utility type,
         * where Obj[Prop] is a primitive
         */
        ? Iterate<Obj[Prop], Path>
        /**
         * Otherwise, call Iterate with Obj[Prop] where
         * Obj[Prop] is an object and puch current Prop into
         * Path tuple
         */
        : Iterate<Obj[Prop], [...Path, Prop]>
    }[keyof Obj]

type Result = Iterate<Data>


Result type is a union, and we need to merge (intersect the union). This is why I it worth using UnionToIntersection:


type Data = {
    k1: {
        k1A: "k1A",
        k1B: "k1B",
        k1C: {
            k1C1: "k1C1",
            k1C2: "k1C2",
        },
    },
    k2: {
        k2A: {
            k2A1: "k2A1",
            k2A2: "k2A2",
        },
        k2B: {
            k2B1: "k2B1",
            k2B2: "k2B2",
        },
    },
    k3: {
        k3A: 'k3A'
    }
}

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
    k: infer I
) => void
    ? I
    : never;

type Values<T> = T[keyof T]

type Iterate<Obj, Path extends any[] = []> =
    UnionToIntersection<
        Obj extends string
        ? Record<Obj, Path>
        : Values<{
            [Prop in keyof Obj]:
            Prop extends Obj[Prop]
            ? Iterate<Obj[Prop], Path>
            : Iterate<Obj[Prop], [...Path, Prop]>
        }>>

type MakeReadable<T> = {
    [Prop in keyof T]: T[Prop]
}

// type Result = {
//     k3A: ["k3"];
//     k1A: ["k1"];
//     k1B: ["k1"];
//     k1C1: ["k1", "k1C"];
//     k1C2: ["k1", "k1C"];
//     k2A1: ["k2", "k2A"];
//     k2A2: ["k2", "k2A"];
//     k2B1: ["k2", "k2B"];
//     k2B2: ["k2", "k2B"];
// }
type Result = MakeReadable<Iterate<Data>>

Playground

You can find more explanation in my article

Upvotes: 2

tenshi
tenshi

Reputation: 26327

@captain-yossarian's answer is effective; I just wanted to throw in my two cents with mapped types:

type DeepestKeys<T> = T extends string ? never : {
    [K in keyof T & string]: T[K] extends string ? K : DeepestKeys<T[K]>;
}[keyof T & string];

type DeepestPaths<T, Path extends string[] = []> = T extends string ? Path : {
    [K in keyof T & string]: DeepestPaths<T[K], [...Path, K]>;
}[keyof T & string];

type ExcludePath<T, Key extends string, Path extends string[] = []> = T extends string ? Path : {
    [K in keyof T & string]: K extends Key ? T[K] extends string ? never : ExcludePath<T[K], Key, [...Path, K]> : ExcludePath<T[K], Key, [...Path, K]>;
}[keyof T & string];

type PathTo<T, Key extends string> = Exclude<DeepestPaths<T>, ExcludePath<T, Key>> extends [...infer Path, infer _] ? Path : never;

type GetKeyPaths<T> = { [K in DeepestKeys<T>]: PathTo<T, K>; };

I also think it's a bit more readable and easy to understand; so let me walk you through how it works.

First we make a type that gets the deepest nested keys. We go through each key in T. If the value is a string, then we've probably found a deepest key. Otherwise we "call" DeepestKeys on the value. Note that DeepestKeys ALWAYS gives us strings. The base case T extends string ? never : is to satisfy the compiler that DeepestKeys is not infinite. So after we go through each key, we access each key using [keyof T & string], which gives us a union of the deepest keys that were found.

Next is DeepestPaths. We find the deepest paths leading to the deepest keys in a similar fashion. This one is more simple because it's just recursion and keeping track of the path it took. When it finds a string it gives us the path it found. Then in the same way, [keyof T & string] gives us a union of the path.

Then we've got ExcludePaths, which does the same thing as DeepestPaths but you give it a key to exclude from the resulting union of paths. You do this with some conditional types and never. Because A | never is the same thing as A it's effectively excluding that path.

So that means if you exclude ExcludePaths from DeepestPaths you actually get the desired path. This is what PathTo does for us. It also removes the last key because we don't need it.

Finally, GetKeyPaths goes through each deepest key and gets the path to it.

Playground

Upvotes: 4

Related Questions