Search code examples
typescripttypescript-genericsform-generator

Strongly type form generator config array


I created a form generator that takes a configuration and renders forms. The basic structure looks something like this:

const sampleConfig = {
  meta: 'someMetaData',
  fields: [
    {
      controlName: 'a',
      type: 'text'
    },
    {
      controlName: 'b',
      type: 'select'
    },
    {
      controlName: 'c',
      type: 'subGroup',
      fields: [
        {
          controlName: 'cA',
          type: 'number'
        }
      ]
    }
  ]
}

This config would create 3 form fields: a text field, a select field and a subgroup with a number field. You can see that each field can either be a control or have a subgroup with its own array of fields.

I have already strongly typed the configuration, for this example let's assume it's something like this:

interface Field {
  controlName: string;
  type: 'number' | 'select' | 'text' | 'subGroup'
  fields?: Field[]
}

interface FormConfig {
  meta: string
  fields: Field[]
}

But I would like to enable passing a generic to verify the controlNames against a corresponding model.

For this example the model would look like this:

interface SampleModel {
  a: string;
  b: string;
  c: {
    cA: number;
  }
}

Ideally I would be able to type the config like this:

const sampleConfig: FormConfig<SampleModel> = { ... }

And if it would throw a compilation error if my control names didn't align with the keys. I can pretty easily ensure that a controlName matches one of the models keys with something like this:

interface Field<M, K extends keyof M> {
  name: K;
  type: 'select' | 'text' | 'number'
}

interface SubGroupField<M, K extends keyof M> extends Omit<Field<M, K>, 'type'> {
  type: 'subGroup',
  fields?: Field<M[K], keyof M[K]>[];
}

interface FormConfig<M, K extends keyof M> {
  meta: 'string';
  fields: Field<M, K>[] | SubGroupField<M[K], keyof M[K]>[];
}

Though this doesn't tightly bind the config to the model, it just ensures the control names are limited to the models key name, the same controlName could be used for each field and Typescript wouldn't complain as long as it matched one of the key names of the model.

This would be very easy to accomplish if I had made the fields property an object instead of an array and used the controlNames as keys to each field object. Unfortunately, this library has been around for a while and I need to avoid a major breaking change if possible.

Obviously this could be useful all types of scenarios, so if you have a better title for the question I'm happy to change it to help others find it more easily. I searched pretty thoroughly for similar questions but couldn't find any, so if this has already been answered I'll update to point there as well.

Any direction on this would be much appreciated. Thanks!

EDITED: Includes suggestion from @Linda Paise to segregate 'subGroup' to own type, enforcing that only this type can have fields property.


Solution

  • I am assuming that you want to keep the exact structure of the model. Meaning that a and b are simple fields but c must be a 'subGroup' with field cA.

    This would be very easy to accomplish if I had made the fields property an object instead of an array and used the controlNames as keys to each field object. Unfortunately, this library has been around for a while and I need to avoid a major breaking change if possible.

    Basically what we are going to do is map the object to the keyed object of fields that you are describing. And then convert it to an array that is the a union of all of the values of that object.

    type Values<T> = T[keyof T][];
    

    The array does have some disadvantages compared to a keyed object because we do not get errors on missing fields or duplicate fields. But we do get errors on invalid fields.

    We can do better than just having fields as an optional property of a Field. We can say that if the key is for an object property of the model, then the field must be a 'subGroup' whose fields match the properties of that object (note that this will apply to arrays and might not be what you want in that case). If the key is for a primative value of the model, then the type is one of 'number' | 'select' | 'text' and there is no fields property.

    We also say that the controlName must be the key and not just any string. This allows us to achieve strict typing even in the flattened version.

    Putting that all together, we get this:

    type Values<T> = T[keyof T][];
    
    type MapFields<Model> = {
      [K in keyof Model]: Model[K] extends object ? {
        controlName: K;
        type: 'subGroup';
        fields: Values<MapFields<Model[K]>>;
      } : {
        controlName: K;
        type: 'number' | 'select' | 'text';
      }
    }
    
    type Config<Model> = {
      meta?: any;
      fields: Values<MapFields<Model>>;
    }
    

    Typescript Playground Link