typescriptunion-typesrecursive-type

How to extract "path expression" from an interface in TypeScript?


What I want to achieve is this:

type Post = {
  id: number
  title: string
  author: {
    name: string
  }
  comments: {
    text: string
  }[]
}

type ExtractPathExpressions<T> = ???

type Paths = ExtractPathExpressions<Post>
// expects above got a union --> 'id' | 'title' | 'author' | 'author.name' | 'comments' | `comments[${number}]` | `comments[${number}].text`

I know this is unusual... but, does anyone know what would the ExtractPathExpressions look like?


Solution

  • This is certainly not an unusual task, but it is a complex recursive one that requires separate handling for different cases where a property:

    1. is of a primitive type
    2. is a nested object
    3. is a nested array

    Recursion is required for cases 2 and 3 as both can contain other nested objects and arrays.

    You want to create a union of all possible permutations of paths, so at each step, we have to return a union of the key itself and the template literal concatenating the key and a result of the recursive ExtractPathExpressions on the property unless it is of primitive type.

    The type itself should obviously be a mapped type (in the sample below I opted for the newer key remapping feature) with keys that can be used in template literal types (a union of string | number | bigint | boolean | null | undefined), which means the symbol type must be excluded.

    This is how the desired type could look like:

    type ExtractPathExpressions<T, Sep extends string = "."> = Exclude<
      keyof {
        [P in Exclude<keyof T, symbol> as T[P] extends any[] | readonly any[]
          ?
              | P
              | `${P}[${number}]`
              | `${P}[${number}]${Sep}${Exclude<
                  ExtractPathExpressions<T[P][number]>,
                  keyof number | keyof string
                >}`
          : T[P] extends { [x: string]: any }
          ? `${P}${Sep}${ExtractPathExpressions<T[P]>}` | P
          : P]: string;
      },
      symbol
    >;
    

    Testing it out:

    type Post = {
      id: number
      title: string
      author: {
        name: string
      }
      comments: {
        text: string,
        replies: {
            author: {
                name: string
            }
        }[],
        responses: readonly { a:boolean }[],
        ids: string[],
        refs: number[],
        accepts: readonly bigint[]
      }[]
    }
    
    type Paths = ExtractPathExpressions<Post>;
    //"id" | "title" | "author" | "comments" | "author.name" | `comments[${number}]` | `comments[${number}].text` | `comments[${number}].replies` | `comments[${number}].responses` | `comments[${number}].ids` | `comments[${number}].refs` | `comments[${number}].accepts` | `comments[${number}].replies[${number}]` | `comments[${number}].replies[${number}].author` | `comments[${number}].replies[${number}].author.name` | ... 4 more ... | `comments[${number}].accepts[${number}]`
    

    Playground