Search code examples
typescriptgenericstype-inference

How can I accurately determine the type in this particular scenario?


Consider the function f, which is defined as follows:

function f<T extends Fields = Fields>(props: Props<T>) {
  return null;
}

In this function, T represents a generic type that extends Fields, with Fields defined as:

export type Fields = { [key: string]: unknown };

Furthermore, the Props interface is defined as:

export interface Props<T extends Fields = Fields> {
  fields: Config<T>;
  onSubmit?: (values: Values<T>) => void;
}

Here, Props accepts a generic type T that extends Fields, and it consists of two properties: fields and onSubmit. The fields property is of type Config<T>, and the onSubmit property is an optional function that takes values of type Values<T> and returns void.

To provide more context, Config and Values are defined as follows:

type BaseProps<T> = {
  initialValue: T;
  hidden?: boolean;
};

export interface TextInput extends BaseProps<string>, TextInputProps {
  type: 'text';
}

export interface Checkbox extends BaseProps<boolean> {
  type: 'checkbox';
}

type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Values<T extends Fields> = {
  [K in keyof T]: Config<T>[K]['initialValue'];
};

Here, Config<T> represents a mapped type where each key in T is mapped to either a TextInput or Checkbox. On the other hand, Values<T> represents a mapped type where each key in T is mapped to the initial value of the corresponding field in Config<T>.

In summary, the function f expects props of type Props, which contains information about the form fields (fields) and an optional submit function (onSubmit). The fields property is defined using a mapped type (Config<T>), and the initial values of these fields are extracted using another mapped type (Values<T>).

The core question here is whether there's a method for Values to automatically infer the correct type. Presently, the values.age type is inferred as string | boolean. This ambiguity stems from the use of the 'or' operator within Config, which allows for either a TextInput or Checkbox.

The concern is not merely about the technical aspect but also about the design implications. Is there a structural or architectural adjustment that can lead to more precise type inference? Or is this ambiguity inherent in the design and therefore acceptable?

In essence, we're exploring whether there's a way to refine the type inference mechanism to accurately determine the type of values.age.

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      initialValue: true,
      type: 'checkbox',
    },
  },
  onSubmit: (values) => {
    console.log(values.age);
  },
});

I'll provide the entire content here for convenient copying.


type BaseProps<T> = {
  initialValue: T;
  hidden?: boolean;
};

export interface TextInput extends BaseProps<string> {
  type: 'text';
}

export interface Checkbox extends BaseProps<boolean> {
  type: 'checkbox';
}

type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Fields = { [key: string]: unknown };
export type Values<T extends Fields> = {
  [K in keyof T]: Config<T>[K]['initialValue'];
};

export interface Props<T extends Fields = Fields> {
  fields: Config<T>;
  onSubmit?: (values: Values<T>) => void;
}

function f<T extends Fields = Fields>(props: Props<T>) {
  return null;
}

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      initialValue: true,
      type: 'checkbox',
    },
  },
  onSubmit: (values) => {
    console.log(values.age);
  },
});

I experimented with several approaches to infer that, but unfortunately, none of them proved successful.


Solution

  • Since your goal is to actually restrict fields to an object whose properties are some element of the union TextInput | Checkbox, as keyed by the type property, we should make a mapping from that property to the allowed member of the union:

    type AllowableProps = TextInput | Checkbox;
    
    type TypeMap = { [T in AllowableProps as T['type']]: T };
    /* type TypeMap = {
        text: TextInput;
        checkbox: Checkbox;
    } */
    

    (Note that you can add members to AllowableProps as needed.)

    Now we can use the mapping make things generic in a object type T which connects field names to the type of input, like {name: "text", age: "checkbox"}:

    type Config<T extends Record<keyof T, keyof TypeMap>> =
      { [K in keyof T]: { type: T[K] } & TypeMap[T[K]] };
    
    interface Props<T extends Record<keyof T, keyof TypeMap>> {
      fields: Config<T>;
      onSubmit?: (values: { [K in keyof T]: TypeMap[T[K]]['initialValue'] }) => void;
    }
    
    function f<T extends Record<keyof T, keyof TypeMap>>(props: Props<T>) {
      return null;
    }
    

    We've constrained T to a type whose property values are keys of TypeMap.

    Then Config<T> maps over the properties of T into the appropriate fields property type; for each key K in keyof T, T[K] is the relevant key of TypeMap. So TypeMap[T[K]] is either TextInput or Checkbox depending on K. And intersection with {type: T[K]} lets the compiler infer T from fields, since it knows it can just inspect the type property.

    Then the type of onSubmit is a function whose argument is of the mapped type { [K in keyof T]: TypeMap[T[K]]['initialValue'] }, which essentially recovers the underlying data type by indexing into either TextInput or Checkbox with the initialValue key.


    Let's test it out:

    f({
      fields: {
        name: {
          type: 'text',
          initialValue: 'John Doe',
        },
        age: {
          initialValue: true,
          type: 'checkbox',
        },
      },
      onSubmit: (values) => {
        console.log(values.age);
        //                 ^? (property) age: boolean
      },
    });
    

    Looks good. If you inspect the call the f with IntelliSense, you'll see that T is inferred as {name: 'text', age: 'checkbox'}, as desired. That means values is contextually typed as {name: string, age: boolean} and you get the behavior you're looking for.

    Playground link to code