I've answered this sort of "deep indexing" question before, but it seems like there are always slightly different use cases. The basic approach is to use template literal types to split the path string at dot characters "."
and use a recursive conditional type to walk down through the nested properties. I'll refer to your GetTypeByPath<T, K>
as DeepIndex<T, K>
to be consistent with prior instances of this question.
Anyway, the implementation looks something like:
type DeepIndex<T, K extends string> = T extends object ? (
K extends `${infer F}.${infer R}` ? DeepIndex<Idx<T, F>, R> : Idx<T, K>
) : never;
where Idx<T, K>
represents the "shallow" indexed access operation normally written as just T[K]
. We'll come to how to implement Idx<T, K>
(and why we don't just write T[K]
) in a bit.
For now, let's just convince ourselves that DeepIndex<T, K>
does what we expect. First, we won't try indexing into primitives, so the whole thing is wrapped in T extends object ? ( ... ) : never
. We could index into primitives, but then we'd be supporting things like DeepIndex<Obj, 'a.b.5.length.toPrecision'>
evaluating to (precision?: number | undefined) => string
. If you remove that wrapper then that's what you'd get.
The heart of the type is just: try to split the path at the first dot into the first part of the string F
and the rest of the string R
. If you can do that, then you want to DeepIndex
into the T[F]
property at the path R
. If you can't, then there are no more dots and we'll just treat the path K
as a single key and return T[K]
.
So how do we implement Idx<T, K>
? One fairly universal complication is that there's no simple way to express to the compiler that the path fragment at K
will definitely be one of the keys of T
. So one thing we'd need to do is check for that. We can try it as:
type Idx<T, K extends string> = K extends keyof T ? T[K] : never;
And this kind of works:
type X = DeepIndex<Obj, 'a.b'> // string[]
Until it doesn't:
type Y = DeepIndex<Obj, 'c.5'> // never
And this is the place where your use case differs from others I've seen. You are indexing into a number[]
with the string value "5"
. But the Array<T>
type definition has a number
index signature, and "5"
is not a number
. It's a "numeric string", so you should be able to index into an array with it (since object keys in JavaScript are actually strings and not really numbers), but the compiler doesn't see "5" extends keyof (number[])
as being true.
So we need to account for such numeric string keys. Let's try this:
type Idx<T, K extends string> =
K extends keyof T ? T[K] :
K extends `${number}` ? number extends keyof T ? T[number] : never : never;
Here we're saying that if K
is a string version of a number (the "pattern template literal" `${number}`
syntax was introduced in microsoft/TypeScript#40598), and if the type T
has a numeric index signature, then we should index into T
with number
instead of K
. Let's test it out again:
type X = DeepIndex<Obj, 'a.b'> // string[]
type Y = DeepIndex<Obj, 'c.5'> // number
Looks good!
Of course there are tons of edge cases. For those come come later, even if this meets the needs of the original person asking the question, there's no guarantee it will do what you expect... so you should really test against all your use cases.
For example: it's quite hard to check if K
is a valid path before you do the indexing. Instead of a compiler warning if you write DeepIndex<Obj, "something.that.is.definitely.not.a.path">
, you'll just get the never
type out, which may or may not be acceptable:
type Z = DeepIndex<Obj, 'oopsieDaisy'> // never
So test carefully!
Playground link to code