Search code examples
typescripttype-inferenceunion-types

TypeScript build union type from complex object


I have the following object:

const object = {
  root: {
    path: 'path1',
  },
  paths: [
    {
      path: 'path2',
    },
    {
      path: 'path3',
    }
  ],
};

and I would like to build a union type that would look like the following:

type extractedPaths = 'path1' | 'path2' | 'path3';

Is there a way to achieve this with TypeScript?


Solution

  • First, you will need to change your initialization of object so that the compiler knows it's supposed to care about the literal types of the string-valued properties. Otherwise you'll get a value of type {root:{path:string},paths:{path:string}[]} and then no matter what you do you'll get just string out. So let's use a const assertion to get a more specific type for object:

    const object = {
      root: {
        path: 'path1',
      },
      paths: [
        {
          path: 'path2',
        },
        {
          path: 'path3',
        }
      ],
    } as const;
    

    That gives us the following type:

    /* const object: {
        readonly root: { readonly path: "path1"; };
        readonly paths: readonly [
          { readonly path: "path2"; }, 
          { readonly path: "path3"; }
        ]; 
      } */
    

    Great, the compiler knows about "path1", "path2", and "path3".


    Then you will need to determine how to identify the types you care about. One possibility is to recursively descend through the type of object and build up a union of any string literal property value types you find along the way. Here's a recursive conditional type that does this:

    type DeepStringLiteralValues<T> =
      T extends string ? string extends T ? never : T :
      T extends readonly any[] ? DeepStringLiteralValues<T[number]> :
      T extends object ? { [K in keyof T]-?: DeepStringLiteralValues<T[K]> }[keyof T] : never;
    

    For any type T, if T is itself a string literal type, then DeepStringLiteralValues<T> will just be T. Otherwise, if it's an arraylike type, we evaluate DeepStringLiteralValues<T[number]> for each element type of the array. Otherwise, if it's an objectlike type, we gather the union of DeepStringLiteralValues<T[K]> for every key K in keyof T. Otherwise, it's not a string or an array or an object, so we ignore it and return never. Let's see what that does:

    type ExtractedPaths = DeepStringLiteralValues<typeof object>
    // type ExtractedPaths = "path1" | "path2" | "path3"
    

    Looks good.


    It's also conceivable that your intent is to just get a union of all recursive properties with the key named "path". If so you can do this instead:

    type DeepPropKey<T, K extends PropertyKey> =
      K extends keyof T ? T[K] :
      T extends readonly any[] ? DeepPropKey<T[number], K> :
      T extends object ? { [P in keyof T]-?: DeepPropKey<T[P], K> }[keyof T] :
      never;
    

    It's similar to the other version, but DeepPropKey<T, K> is trying to find property value types for properties with key K, instead of grabbing any string literal it can find. For your example, it produces the same output, though:

    type ExtractedPaths = DeepPropKey<typeof object, "path">
    // type ExtractedPaths = "path1" | "path2" | "path3"
    

    Playground link to code