Be warned: even with language support for recursive conditional types, it is quite easy for deep indexing operations to run afoul of the compiler's recursion limiters. Even relatively minor changes can mean the difference between a version that seems to work and one that bogs down the compiler or issues the dreaded error: "⚠ Type instantiation is excessively deep and possibly infinite. ⚠
". The version of DeepKeyOf
presented here seems to work, but it's definitely walking on a tightrope above an abyss of circularity.
Additional warning: something like this invariably has all sorts of edge cases. You might not be happy with how this (or any) version of DeepKeyOf<XYZ>
handles things in cases where the type XYZ
: has an index signature; is a union of types; is recursive like type Recursive = { prop: Recursive };
; et cetera. It's possible that for each edge case there is a tweak that will behave "better" in your opinion, but handling all of them is probably outside the scope of this question.
Okay, warnings over. Let's look at DeepKeyOf<T>
:
type DeepKeyOf<T> = (
[T] extends [never] ? "" :
T extends object ? (
{ [K in Exclude<keyof T, symbol>]:
`${K}${undefined extends T[K] ? "?" : ""}${DotPrefix<DeepKeyOf<T[K]>>}` }[
Exclude<keyof T, symbol>]
) : ""
) extends infer D ? Extract<D, string> : never;
type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;
Just to be sure, let's test it on Test
:
type DeepKeyOfTest = DeepKeyOf<Test>
// type DeepKeyOfTest = "foo.foo" | "foo.bar?" | "foo.bar?.foobar" | "foo.bar?.barfoo"
// | "foo.boo.far?" | "foo.boo.far?.farboo"
Looks good.
Let's walk through it and see how it works:
type DeepKeyOf<T> = (
[T] extends [never] ? "" :
Here we will make DeepKeyOf<never>
explicitly return the empty string ""
. Something like is necessary if you want to mostly distribute DeepKeyOf<T>
over unions in T
while still having properties whose type is only never
show up. As I said in the comments, I'm a bit skeptical of this being desired behavior. Distributing over unions is nice because it automatically makes DeepKeyOf<{a: string} | undefined>
equivalent to DeepKeyOf<{a: string}> | DeepKeyOf<undefined>
. But then DeepKeyOf<never>
really should be never
, to be consistent (since any type X
is equivalent to X | never
). Anyway, this is coming down to edge cases again so I'll move on:
T extends object ? (
If T
is not a primitive type then we will produce keys of some kind. Note that arrays and functions are not primitives, if it matters.
{ [K in Exclude<keyof T, symbol>]:
We will first make a mapped type with the same keys as T
except for any possible symbol
-valued keys. Removing symbol
is important to allow every key K
to be used in template literal types.
`${K}${undefined extends T[K] ? "?" : ""}${DotPrefix<DeepKeyOf<T[K]>>}` }
This is the workhorse of the type. For each key K
we start a new string with K
. Then, if the property type at key K
, namely T[K]
can accept an undefined
value, we append "?"
. Finally, we append DotPrefix<DeepKeyOf<T[K]>>
, where DeepKeyOf<T[K]>
is expected to be the union of all keys of the property T[K]
, and DotPrefix
takes care of optionally including the "."
character, explained below.
[Exclude<keyof T, symbol>]
The mapped type we created now looks something like {a: "a.foo" | "a.bar"; b: "b"}
, but we want something like "a.foo" | "a.bar" | "b"
instead. We do this by indexing into the mapped type with the same keys we used to create it.
) : ""
If T
is neither never
nor a primitive, we will produce the empty string ""
. So DeepKeyOf<string>
will be ""
.
) extends infer D ? Extract<D, string> : never;
This line really shouldn't be necessary, but it prevents recursion depth warnings. Essentially by writing extends infer D
we are copying the result into a new parameter D
and causing the compiler to defer evaluation that it would otherwise perform eagerly. The Extract<D, string>
lets the compiler understand that DeepKeyOf<T>
will always produce a subtype of string
so that the recursive step will succeed.
Finally,
type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;
will take something like "foo" | "bar" | ""
and produce ".foo" | ".bar" | ""
. It prepends a dot to its input unless that input is the empty string. Without such an exception you'd have types like "foo.bar.baz."
that end in a dot.
Playground link to code