TS4.1: Is there a way to define this property path type that isn't excessively deep?

Viewed 945

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?

3 Answers

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')

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[]).

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

Related