3

Experimenting with the upcoming TypeScript 4.1's template literal types, I tried to define a generic type that can check property paths.

Until TS 4.1, there was no possible way to type an expression such as 'foo.bar.baz', and you would have to settle for string. Now, with template literal types, I want to be able to type these property paths, and use them for things such as MongoDB queries and projection objects. For example:

db.someCollection.find({ 'foo.bar.baz': { $exists: true } });

This is the type that I came up with:

type PropsPath<T> =
    T extends object
        ? T extends any[]
            ? number
            : {
                [P in keyof T]: P | `${P}.${PropsPath<T[P]>}`
            }[keyof T]
        : '';

Full example in TS Playground

Sadly, this type is considered "excessively deep or possibly infinite" by TS compiler. Is there any way to redefine it in a way that doesn't throw an error?

Amit Beckenstein
  • 1,220
  • 12
  • 20
  • 1
    see here: https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object/58436959#58436959 – Yao Zhao May 20 '21 at 08:45

3 Answers3

3

With TS 4.0, you can use Variadic Tuple Types to have the exact element type of an Array type. Combine that with Recursive Type Aliases, you can achieve what you want. Check out the TS Playground

TS Playground

Further explanation:

  • ExtractPropsPath<T>: This alias only keeps any member of T that is string[]. Eg: ['arr'] | ['arr', ...any[]], this alias will only keep ['arr']
  • Join: Join an [...Elements][] with a delimiter D. In this case, the delimiter is a dot .
  • PathOf: the meat of the solution is here. PathOf returns a Union Type that will:
    • Returns [key] if T[key] is a primitive
    • Returns [key] | [key, ...nested-if] if T[key] is an object (note the ..., we will be returning array type)
    • "nested-if" is where we check if
      • T[key] is an Tuple with 1 member (eg: [number]) then return ['0'] if member type is primitive, or we return recursive PathOf<member> (mark this point as {1})
      • T[key] is a Tuple with multiple members or an array type (eg: [number, string] or number[]) then
        • Check if the tuple is a union (eg: [number, string] -> number | string) then just return the recursive PathOf
        • If it's not the union, then it's an array type which we can use the same logic as {1}
  • SerializedPathOf: Basically turns PathOf which is a union of string[] to a joined union of string with the delimiter . (eg: ['obj'] | ['obj', 'num'] -> 'obj' | 'obj.num')
Chau Tran
  • 4,668
  • 1
  • 21
  • 39
  • 1
    That's really cool, even better than my own solution. Well done! I wish there was a way to support `'arr3.'`. There's a problem with the `Join` definition though, in line 7. – Amit Beckenstein Oct 02 '20 at 15:10
  • 1
    Thanks. What do you mean by `'arr3.'`? And yeah that `Join` is messing with me. Not sure why since I did assert `T extends string[]` so theoretically, `[unknown, ...infer U]` should infer `U` as a `string[]` but oh well. PS: changing `unknown` to `string` didn't fix it :) – Chau Tran Oct 02 '20 at 15:41
  • I meant that I wished it could accept `arr3.100` (100 is just a random number) since `arr3` is of an unknown length. – Amit Beckenstein Oct 03 '20 at 23:52
  • I was trying to implement something the same but much smaller. (Currently no array support). type GetObjectPossiblePaths = TPath extends `${infer Left}.${infer Right}` ? Left extends keyof TData ? `${Left}.${GetObjectPossiblePaths}` : never : TPath extends keyof TData ? TPath : never; But this doesn't work. If you have som time - could you help me to understand why this doesn't work, please? My solution shows only 1 level of properties. It doesn't show 2 lvl as autocompletion but yells if 2 lvl doesn't exist. – Link Jun 19 '22 at 13:12
1

To answer my own question, I finally came up with the following type:

type PropsPath<T extends object> = {
    [P in keyof T]: T[P] extends object
        ? `${string & P}` | `${string & P}.${PropsPath<T[P]>}`
        : `${string & P}`
}[T extends any[] ? (number & keyof T) : keyof T];

Updated example in TS Playground

It works, but sadly doesn't cover cases where T has a nested property which is an array of unknown length (any[]).

Amit Beckenstein
  • 1,220
  • 12
  • 20
  • Thanks! I'll use that! Here's a newbie question: I noticed your type creates an object and immediately selects a property from that object, using keyof P. Why? I was hoping something like this would work: https://www.typescriptlang.org/play?ts=4.1.0-dev.20200921&ssl=5&ssc=17&pln=1&pc=1#code/C4TwDgpgBACgTgezAZxgQ2ACwDwBUoQAewEAdgCbJQIBGAVhAMbAA0sUAvFANYQgIAzKLgB8nAFBQpUmAWJlKPPoOFQA-FEnTtuANowAunJIUqtBs3WxEKdFjz6DIrdqkAuKAAMAJAG9kwHAAlqQA5lAAZLAAvp4urh6kEABuEHAA3OLiISRwAmiM0ADKCAC2ELjg0L7x5h41rtKkAK6lia00aZna0ZnRWYwIpAFQYABMHvBIqBg4JeWVkGJcAOTmAHQtpSuZ4kA – Marcel van der Drift Oct 04 '21 at 20:03
1

Building on Amit's answer, this works for arrays too, though it doesn't account for tuples like Chau's.

type Path<T> = T extends Array<any>
  ? `${number}` | `${number}.${Path<T[number]>}`
  : T extends object
  ? {
      [P in keyof T]: (P & string) | `${P & string}.${Path<T[P]>}`
    }[keyof T]
  : never

TS Playground

SamBarnes
  • 576
  • 5
  • 4