UPDATE FOR TS 4.1
Now that TypeScript supports recursive conditional types and variadic tuple types, you can write DeepIndex
more simply:
type DeepIndex<T, KS extends Keys, Fail = undefined> =
KS extends [infer F, ...infer R] ? F extends keyof T ? R extends Keys ?
DeepIndex<T[F], R, Fail> : Fail : Fail : T;
This still probably has some "interesting" behavior on tree-like types, but the situation has definitely improved since I wrote the answer below:
Playground link to code.
So, when I tried to write a similar deep-indexing type using the same kind of unsupported recursion as in the linked question, I also kept running into either compiler warnings or slowdowns. This is just one of the problems with pushing the compiler to do things it isn't meant to do. Maybe one day there will be a safe, simple, and supported solution, but right now there isn't. See microsoft/TypeScript#26980 for discussion about getting support for circular conditional types.
For now what I'm going to do is my old standby for writing recursive conditional types: take the intended recursive type and unroll it into a series of non-recursive types that explicitly bails out at some depth:
Given Tail<T>
that takes a tuple type like [1,2,3]
and removes the first element to produce a smaller tuple like [2, 3]
:
type Tail<T> = T extends readonly any[] ?
((...t: T) => void) extends ((h: any, ...r: infer R) => void) ? R : never
: never;
I'll define DeepIndex<T, KS, F>
to be something that takes a type T
and a tuple of key-types KS
and walks down into T
with those keys, producing the type of the nested property found there. If this ends up trying to index into something with a key it doesn't have, it will produce a failure type F
, which should default to something like undefined
:
type Keys = readonly PropertyKey[];
type DeepIndex<T, KS extends Keys, F = undefined> = Idx0<T, KS, F>;
type Idx0<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx1<T[KS[0]], Tail<KS>, F> : F;
type Idx1<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx2<T[KS[0]], Tail<KS>, F> : F;
type Idx2<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx3<T[KS[0]], Tail<KS>, F> : F;
type Idx3<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx4<T[KS[0]], Tail<KS>, F> : F;
type Idx4<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx5<T[KS[0]], Tail<KS>, F> : F;
type Idx5<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx6<T[KS[0]], Tail<KS>, F> : F;
type Idx6<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx7<T[KS[0]], Tail<KS>, F> : F;
type Idx7<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx8<T[KS[0]], Tail<KS>, F> : F;
type Idx8<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? Idx9<T[KS[0]], Tail<KS>, F> : F;
type Idx9<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? IdxX<T[KS[0]], Tail<KS>, F> : F;
type IdxX<T, KS extends Keys, F> = KS['length'] extends 0 ? T : KS[0] extends keyof T ? T[KS[0]] : F;
Here you can see how the Idx
type is nearly recursive, but instead of referencing itself, it references another nearly identical type, eventually bailing out 10 levels deep.
I'd imagine using it like this:
function deepIndex<T, KS extends Keys, K extends PropertyKey>(
obj: T,
...keys: KS & K[]
): DeepIndex<T, KS>;
function deepIndex(obj: any, ...keys: Keys) {
return keys.reduce((o, k) => o?.[k], obj);
}
So you can see that deepIndex()
takes obj
of type T
and keys
of type KS
, and should produce a result of type DeepIndex<T, KS>
. The implementation uses keys.reduce()
. Let's see if it works:
const obj = {
a: { b: { c: 1 }, d: { e: "" } },
f: { g: { h: { i: true } } }, j: { k: [{ l: "hey" }] }
}
const c = deepIndex(obj, "a", "b", "c"); // number
const e = deepIndex(obj, "a", "d", "e"); // string
const i = deepIndex(obj, "f", "g", "h", "i"); // boolean
const l = deepIndex(obj, "j", "k", 0, "l"); // string
const oops = deepIndex(obj, "a", "b", "c", "d"); // undefined
const hmm = deepIndex(obj, "a", "b", "c", "toFixed"); // (fractionDigits?: number) => string
Looks good to me.
Note that I'm sure you'd love to have the deepIndex()
function or the DeepIndex
type actually constrain the KS
type to be those from Paths<T>
instead of outputting undefined
. I tried about five different ways to do this, and most of them blew out the compiler entirely. And the ones that didn't blow out the compiler were uglier and more complicated than the above, and for a kicker they really didn't give useful error messages; a bug I filed an issue about a while ago, microsoft/TypeScript#28505, causes the error to appear on the wrong element of the keys
array. So you'd want to see
const oops = deepIndex(obj, "a", "b", "c", "d"); // error!
// --------------------------------------> ~~~
// "d" is not assignable to keyof number
but what would actually happen is
const oops = deepIndex(obj, "a", "b", "c", "d"); // error!
// -----------------------> ~~~
// "d" is not assignable to never
So I give up. Feel free to work on that more, if you dare. The whole endeavor really pushes things to a level I wouldn't feel comfortable subjecting anyone else to. I see this as "fun and exciting challenge for the compiler" and not "code on which anyone's livelihood should depend".
Okay, hope that helps; good luck!
Playground link to code