Search code examples
typescripttypescript-genericstypescript-types

How to infer/map types dynamically from a given array of objects?


I have an array of objects something like this:

const options = [
  { name: "diameter", type: "number", value: 10, min: 0, max: 10 },
  { name: "height", type: "number", value: 2, min: 0, max: 8 },
  { name: "thickness", type: "number", value: 2, min: 0, max: 8 },
  { name: "text", type: "string", value: "Hello World" },
];

and I'd like to dynamically get the types, so have something like this;

type OptionsType = {
  diameter: { value: number; min: number; max: number };
  height: { value: number; min: number; max: number };
  thickness: { value: number; min: number; max: number };
  text: { value: string };
};

I've tried searching around, asking chatGPT / perplexity and supermaven but failed to have a solution.

Closes solution attempt from AI was this:

// Helper type to determine the correct parameter type based on 'type' property
type ParamType<T extends { type: string }> = T["type"] extends "number"
  ? { value: number; min: number; max: number }
  : { value: string };

// Mapped type to transform the options array into the desired type definition
type OptionsParams<T extends readonly { name: string; type: string }[]> = {
  [K in T[number] as K["name"]]: ParamType<Extract<T[number], { name: K }>>;
};

// Generate the type dynamically
type optionsParams = OptionsParams<typeof options>;

but on this return type of optionsParams is:

type optionsParams = {
    [x: string]: {
        value: number;
        min: number;
        max: number;
    };
}

so it's only works (kind-of) just for values that are numbers.

Any suggestions how to approach this, and how to generate/map types dynamically from a given object array in TypeScript?


Solution

  • TypeScript doesn't infer object property value types as literals by default — so when you initialize the options variable without a type annotation or some kind of assertion (e.g. a type assertion or const assertion), the types of values at the "name" property in the objects are inferred simply as string. It is at this point that the type information that's required for the desired mapped type is already lost, so more narrow inference is needed.

    In order to capture those string literal types that are lost by default inference, you can use the array literal as the argument in a function that will create the object, and instruct the compiler to use literal inference for the "name" properties of each object using a const generic type parameter, like this:

    type MappedOptions<T extends ReadonlyArray<Record<"name", string>>> = {
      [O in T[number] as O["name"]]: Omit<O, "name">;
    };
    
    // You can provide your own implementation for this function:
    declare function toObject<
      const Name extends string,
      T extends ReadonlyArray<Record<"name", Name>>,
    >(array: T): MappedOptions<T>;
    

    Here's a complete example:

    TS Playground

    type MappedOptions<T extends ReadonlyArray<Record<"name", string>>> = {
      [O in T[number] as O["name"]]: Omit<O, "name">;
    };
    
    // You can provide your own implementation for this function:
    declare function toObject<
      const Name extends string,
      T extends ReadonlyArray<Record<"name", Name>>,
    >(array: T): MappedOptions<T>;
    
    const options = [
      { name: "diameter", type: "number", value: 10, min: 0, max: 10 },
      { name: "height", type: "number", value: 2, min: 0, max: 8 },
      { name: "thickness", type: "number", value: 2, min: 0, max: 8 },
      { name: "text", type: "string", value: "Hello World" },
    ];
    
    // String literal types for "name" property already widened to "string"
    // by default compiler inference (the type information is alrady lost)…
    type InferredOptions = typeof options;
    /*   ^? type InferredOptions = ({
      name: string;
      type: string;
      value: number;
      min: number;
      max: number;
    } | {
      name: string;
      type: string;
      value: string;
      min?: never;
      max?: never;
    })[] */
    
    // …so it can't be used to get the desired mapped result:
    const obj0 = toObject(options);
    /*    ^? const obj0: {
      [x: string]: {
        type: string;
        value: number;
        min: number;
        max: number;
      } | {
        type: string;
        value: string;
        min?: never;
        max?: never;
      };
    } */
    
    // In contrast: using an object literal as the function argument
    // allows for narrower inference:
    const obj1 = toObject([
      { name: "diameter", type: "number", value: 10, min: 0, max: 10 },
      { name: "height", type: "number", value: 2, min: 0, max: 8 },
      { name: "thickness", type: "number", value: 2, min: 0, max: 8 },
      { name: "text", type: "string", value: "Hello World" },
    ]);
    
    type Actual = typeof obj1;
    /*   ^? type Actual = {
      diameter: {
        type: string;
        value: number;
        min: number;
        max: number;
      };
      height: {
        type: string;
        value: number;
        min: number;
        max: number;
      };
      thickness: {
        type: string;
        value: number;
        min: number;
        max: number;
      };
      text: {
        type: string;
        value: string;
        min?: never;
        max?: never;
      };
    } */