JD Francis
JD Francis

Reputation: 494

Typescript: Create union type based on existence of sub keys in an object

Wondering if there's a way to use keyof and string templates in typescript to make a union type based on both keys + subkeys

const object = {
  level1Key1: {
    some: "data"
  },
  level1Key2: {
    nestedKey: { more: "data" },
    anotherNestedKey: { more: "data" },
  }
}

Is there any way to generate a type using typeof object to make the following union type without using any hardcoded strings (as in not doing keyof typeof object.level1key2)

type desiredType = "level1Key1" | "level1Key2.nestedKey" | "level1Key2.anotherNestedKey";

Upvotes: 2

Views: 1326

Answers (1)

jcalz
jcalz

Reputation: 327754

Here's one possible approach:

type MyPaths<T extends object> = keyof T extends infer K ? K extends string & keyof T ?
  T[K] extends object ? `${K}${PrependDot<MyPaths<T[K]>>}` : never
  : never : never;

type PrependDot<T extends string> = [T] extends [never] ? "" : `.${T}`;    

This defines a recursive conditional type called MyPaths<T> which takes an object type T and produces a union of dotted paths to property values which are something I'll call shallow objects.

For example, in {a: {b: {c: "d"} } }, the only path you'd get is "a.b". The path "a" points to {b:{c:"d"}}, which is an object, but it has subproperties, so it isn't shallow. The path "a.b.c" points to "d" which isn't an object.
Only "a.b" points to a shallow object, {c:"d"}.

Here's how it works. First, we want MyPaths<T> to take the set of keys keyof T and split them up into individual string keys K. For each such key, we will perform the necessary type operation, and then we will collect the results into one big union. We do this by making MyPaths<T> a distributive conditional type over keyof T. Hence the keyof T extends infer K ? K extends string & keyof T ? ... : never : never, where ... is the type operation for each K.

That type operation is another conditional type check; if the property type of T at key K (aka T[K]) is not an object (aka object), then we want to return nothing (aka never) for this property. If the property is an object, then we need to prepend the current key K to the result of a recursive call to MyPaths<T[K]>, give or take a dot.

I mean, if MyPaths<T[K]> is never, then T[K] is a shallow object and we want to just return the current key K with no dot after it. But if MyPaths<T[K]> is some union of strings, then we want to add a dot in between. That's pretty much the way PrependDot<T> is implemented (put a dot before the string T unless T is never, in which case, just use an empty string).

That's what `${K}${PrependDot<MyPaths<T[K]>>}`, does. It's a template literal type that does the string concatenation of K and MyPaths<T[K]>, with PrependDot taking care of whether or not to insert a dot after K or just an empty string.


Let's test it out:

type DesiredType = MyPaths<typeof object>;
// type DesiredType = "level1Key1" | "level1Key2.nestedKey" | "level1Key2.anotherNestedKey"

Looks good to me!

Playground link to code

Upvotes: 3

Related Questions