2

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";
JD Francis
  • 454
  • 1
  • 4
  • 22
  • Does this answer your question? [Typescript: deep keyof of a nested object](https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object) – Tobias S. Apr 20 '22 at 19:10
  • By what criterion are you excluding `"level1Key1.some"` and `"level1Key2"` from your desired type? – jcalz Apr 20 '22 at 19:33
  • @jcalz, by the value type being a string rather than an object – JD Francis Apr 20 '22 at 20:33
  • The value type of `object.level1Key2` is not a `string`, and yet it is not present either. – jcalz Apr 20 '22 at 20:38
  • 1
    Does [this approach](https://tsplay.dev/mp9QpW) work for you? Please test it out against your use cases. If it meets them I can write up an answer explaining it; otherwise, what am I missing? – jcalz Apr 20 '22 at 21:02
  • @jcalz That seems to be working the way I expect. An explanation would be appreciated! – JD Francis Apr 20 '22 at 21:39

1 Answers1

3

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

jcalz
  • 264,269
  • 27
  • 359
  • 360