1

My requirement is that the type alias can be passed in two parameters, the first one is the object type, the second one is a dot expression that indicates the path of the field.

For example:

type Obj = {
  a: {
    b: string[]
  }
  c: number[]
}

GetTypeByPath<Obj, 'a.b'>  // => string[]
GetTypeByPath<Obj, 'c.5'>  // => number

So how the GetTypeByPath type would be look like?

yaquawa
  • 6,690
  • 8
  • 35
  • 48
  • 1
    Does [this approach](https://tsplay.dev/Nlgz5w) work for your use cases? If not, let me know what I'm missing. – jcalz Feb 12 '22 at 22:32
  • @jcalz Wow..!!! That looks perfect! Thanks man! Could you leave some comment for me? because that looks too magical to me..! – yaquawa Feb 12 '22 at 22:52
  • I will write up an answer when I get a chance; please check against your use cases first, though… these sort of “deep indexing” operations tend to have weird edge cases and different people want to see different things happen. I hate to spend too much time on an answer and then have to change the whole thing because of a missed use case. – jcalz Feb 12 '22 at 23:09
  • @jcalz I've test your solution in my usecase, and that just works perfectly. I really appreciate it! I'm wondering how the 'Idx' is actually work, and what does 'Idx' stands for? – yaquawa Feb 12 '22 at 23:47
  • I refactored my `Idx` type (which stands for "Index", btw) to something easier to explain which serves the same purpose (but please check it again!) – jcalz Feb 13 '22 at 03:17

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    Fantastic explanation!! Again, thank you so much!! And, could you just wrap this code into "source code"? ---> `type Idx = K extends keyof T ? T[K] : K extends ${number} ? number extends keyof T ? T[number] : never : never;` – yaquawa Feb 18 '22 at 01:35
  • Whoops, fixed. – jcalz Feb 18 '22 at 04:16