typescripttypescript-genericsnested-generics

Typescript, narrowing nested generic types


I am building a type safe form schema. One of the form entries needs perform keyof checking of a subset of the form type. I am at a loss how to pass & narrow the generic type to the sub type.

Here is a link to ts playground where I have tried to work out a simple version of what I am working on. playground

Specifically I want the fields properties of the FieldArray to be type safe. The same way that the Schema type is. I am at a loss as to how to narrow or even pass the type for fields

type FieldType = "text-input" | "number" | "dropdown" | "checkbox";

type Field = {
  label: string;
  type: FieldType;
};

type FieldName<T> = T[keyof T] extends (infer I)[] ? I : never;

type FieldArray<T> = {
  type: "array";
  groupLabel: string;
  fields: Record<keyof FieldName<T> & string, Field>;
};

type SchemaField<T> = Field | FieldArray<T>;

type Schema<T> = Record<keyof T, SchemaField<T>>;

type Form = {
  workflowName: string;
  id: number;
  rules: { ruleName: string; isActive: boolean; ruleId: number }[];
  errors: { errorName: string; isActive: boolean; errorId: number }[];
};

const formSchema: Schema<Form> = {
  workflowName: { type: "text-input", label: "Name" },
  id: { type: "number", label: "Id" },
  rules: {
    type: "array",
    groupLabel: "Rules",
    fields: {
      ruleName: { label: "Rule Name", type: "text-input" },
      isActive: { label: "Is Active", type: "checkbox" },
      ruleId: { label: "Rule Id", type: "number" },
    },
  },
  errors: {
    type: "array",
    groupLabel: "Errors",
    fields: {
      errorName: { label: "Error Name", type: "text-input" },
      isActive: { label: "Is Active", type: "checkbox" },
      errorId: { label: "Error Id", type: "number" },
    },
  },
};

I expect the fields property of rules and errors to be type safe against their definition in Form

Here's an example of how this is not type safe. In the above I can put any properties in the fields object of errors

the fields object for errors should only be allowed to contain the keys errorName, isActive, errorId as per the definition. But the below entries do not trigger a type warning.

errors: {

    fields: {
      foo: { label: "Error Name", type: "text-input" },
      bar: { label: "Is Active", type: "checkbox" },
      baz: { label: "Error Id", type: "number" },
    },
  },

Solution

  • Given your code example, my inclination would be to define Schema<T> as follows:

    type Schema<T> = { [K in keyof T]: SchemaProp<T[K]> };
    
    type SchemaProp<T> = T extends readonly (infer U)[] ?
      { type: "array", groupLabel: string, fields: Schema<U> }
      : { type: FieldType, label: string }
    

    Instead of using the Record<K, V> utility type in which there is no correlation between the particular keys in K and the corresponding values in V, I've made it a structure-preserving mapped type where each key K in keyof T is mapped to the property type SchemaProp<T[K]>.

    And SchemaProp<T> takes a property type T and converts it into the corresponding "field" type. It checks, via conditional type, whether T is an array with element type U; if so, then it becomes an "array"-typed field where the fields property is itself a Schema<U>. Therefore Schema<T> is a recursive type, meaning that, if you wanted, you could represent schemas of types which contain arrays of arrays, or arrays of arrays of arrays. If the property T is not an array, then you get just one of the FieldType-typed fields.


    Let's make sure this works:

    const goodFormSchema: Schema<Form> = {
      workflowName: { type: "text-input", label: "Name" },
      id: { type: "number", label: "Id" },
      rules: {
        type: "array",
        groupLabel: "Rules",
        fields: {
          ruleName: { label: "Rule Name", type: "text-input" },
          isActive: { label: "Is Active", type: "checkbox" },
          ruleId: { label: "Rule Id", type: "number" },
        },
      },
      errors: {
        type: "array",
        groupLabel: "Errors",
        fields: {
          errorName: { label: "Error Name", type: "text-input" },
          isActive: { label: "Is Active", type: "checkbox" },
          errorId: { label: "Error Id", type: "number" }
        }
      }
    }; // okay
    

    So that works, and if we put the wrong keys in for one of the fields properties, we'll see an appropriate error:

    const badFormSchema: Schema<Form> = {
      workflowName: { type: "text-input", label: "Name" },
      id: { type: "number", label: "Id" },
      rules: {
        type: "array",
        groupLabel: "Rules",
        fields: {
          ruleName: { label: "Rule Name", type: "text-input" },
          isActive: { label: "Is Active", type: "checkbox" },
          ruleId: { label: "Rule Id", type: "number" },
        },
      },
      errors: {
        type: "array",
        groupLabel: "Errors",
        fields: {
          foo: { label: "Error Name", type: "text-input" }, // error!
          // Object literal may only specify known properties, and 'foo' does not exist in type ...
          bar: { label: "Is Active", type: "checkbox" },
          baz: { label: "Error Id", type: "number" },
        }
      }
    };
    

    Note that, depending on your use cases, you might want to modify that definition.

    Maybe you will have an object type with a property that is another object type instead of an array of object types. If so, you'd probably need another conditional check in SchemaProp<T>. Or maybe you will have a property whose type is a union, in which case you'd probably need to make sure that SchemaProp<T> distributes over such unions... or doesn't distribute, depending on your needs.

    Thorough testing is recommended.

    Playground link to code