Trusted answers to developer questions
Trusted Answers to Developer Questions

Related Tags

angular
community creator

How to generate type-safe custom builders for Angular Formly lib

Stephen Cooper

You may have noticed that the typing provided by third party libraries often feels very loose. This should not come as a surprise because it is not always possible to provide stricter typing while maintaining flexibility for all users.

However, there is nothing to stop us from adding a layer of more explicit typing on top of the library interface in our own applications. We can leverage Typescript to greatly improve the developer experience and encourage consistency in how the library is used.

To demonstrate the benefits of this approach, we will work through the second of our use cases with Angular Formly:

We will show how adding a level of explicit types prevents bugs and speeds up development with both of these scenarios.

Formly: Typed Formly config builder

Formly is a fantastic tool for building Angular forms. The core idea is that you define your form components as a list of form fields in your Typescript code.

Then, you pass these to a Formly component, which will render these for you using a predefined set of controls. Just like the columnType for AG Grid, we have a type property on the FormlyFieldConfig that dictates which form control to use.

As an example, say we have a form where we are collecting user information: name, date of birth, and height. We can represent this with the following Formly config.

interface Person {
  name: string;
  dob: Date;
  height: number;
}

class Component{
  model: Person = {};
  formGroup: FormGroup;
  formFields: FormlyFieldConfig = [
    {
      type: 'input'
      key: 'name',
      templateOptions: { ... }
    },
    {
      type: 'date'
      key: 'dob',
      templateOptions: { label: 'Date of Birth', ... }
    },
    {
      type: 'input'
      key: 'height',
      templateOptions: { type: 'number', ... }
    },
  ];
}

With the following template definition, we can render the form.

<form [formGroup]="formGroup">
  <formly-form [model]="model" [fields]="formFields" [form]="formGroup">
  </formly-form>
</form>

This creates our form and the code is clear. However, as our forms become more complex and repetitive, we will quickly find that this approach does not scale so well and can lead to code bugs(Mainly from copy and paste).

Potential bug locations

1) The key does not match a valid model property

The config interface is FormlyFieldConfig, which defines the key property as a plain string. When writing plain string properties, it is very easy to make a typo or forget to update a key following a model refactor.

If this occurs, then Formly will render the form, but the value of the input will be assigned to the wrong property on the model. This could lead to missing information, as your Typescript code will be looking for the value under a different property name. It could even lead to submitted forms that clear user information!

interface Person {
  name: string;
  dob: Date;
  height: number;
}
formFields: FormlyFieldConfig = [
  {
    type: 'date'
    // BUG: key should be 'dob'
    key: 'dateOfBirth',
    templateOptions: { ... }
  }
];

As the type of key is string, our project will build since TypeScript doesn’t see any problems; however, the form is now broken. dob is not equal to dateOfBirth!

2) Control type does not match model property type

Another potential issue you might encounter is a mismatch between the model property type and the Formly control type. For example, you might say the Formly type of the dob property is input instead of a date.

formFields: FormlyFieldConfig = [
  {
    // BUG: type should be date to use a date picker not a text input
    type: 'input'
    key: 'dob',
    templateOptions: { ... }
  }
];

Since there is no typing link between the type and key properties, the build will succeed.However, once again, our form is broken – users should have a date picker and not a string input!

FormlyFieldConfig config builder

Once again, let’s use Typescript to provide solutions to the two issues above. We will do this by creating config builder functions that encapsulate additional typing restrictions, based on the underlying form model.

These builder functions will have the added benefit of dramatically reducing the amount of boilerplate code required to define our forms.

Our first step is to encapsulate the logic required to define each type of form control. Here, we set up text/number inputs as well as a date control. We also apply a default label via a shared function (to avoid having to specify that in most cases).

class FormlyFieldBuilder {
  input(key: string, configOverrides?: FormlyFieldConfig): FormlyFieldConfig {
  return this.applyLabel({
    key,
    type: "input",
    ...configOverrides,
  });
  }
  
  number(key: string, configOverrides?: FormlyFieldConfig): FormlyFieldConfig {
    return this.applyLabel({
      key,
      type: "input",
      ...configOverrides,
      templateOptions: {
        type: "number",
        // Ensure templateOptions are correctly merged
        ...configOverrides?.templateOptions,
      },
    });
  }
  
  date(key: string, configOverrides?: FormlyFieldConfig): FormlyFieldConfig {
    return this.applyLabel({
      key,
      type: "date",
      ...configOverrides,
    });
  }
  
  private applyLabel(config: FormlyFieldConfig) {}
}

Using this builder, we can now define our form as follows.

const fb = new FormlyFieldBuilder();

const formFields: FormlyFieldConfig = [
  fb.input("name"),
  fb.date("date", {
    templateOptions: { label: "Date of Birth" },
  }),
  fb.number("height"),
];

We have reduced our boilerplate code. However, we have not resolved either of our potential bugs, as we can write the following and still have the code compile.

formFields: FormlyFieldConfig = [
  // BUG: Wrong key name
  fb.date("dateOfBirth"),
  // BUG: Wrong form control
  fb.input("height"),
];

Solution

1) The key does not match a valid model property

To solve the first bug, we can make a small change to our functions by making our FormlyFieldBuilder generic. Then, we can use the keyof operator to restrict the value of the key property to those that exist on our model.

class FormlyFieldBuilder<TModel> {
  // Enforce the key to be a valid key of TModel
  input(
    key: keyof TModel,
    configOverrides?: FormlyFieldConfig
  ): FormlyFieldConfig {
    return this.applyLabel({
      key,
      type: "input",
      ...configOverrides,
    });
  }
}

Now, if we write the following code, Typescript will complain and the build fails.

interface Person {
  name: string;
  dob: Date;
  height: number;
}

fb: FormlyFieldBuilder<Person>;
formFields: FormlyFieldConfig = [
  // ERROR: Argument of type '"dateOfBirth"' 
  // is not assignable to parameter of type '"name" | "dob" | "height"
  fb.date("dateOfBirth"),
];

This is fantastic, as now we can catch typos and copy and paste errors immediately. Another major benefit is that we now get auto-complete for our model key properties, which makes setting up these fields significantly easier.

2) Control type does not match model property type

Even with the above change, we have not yet solved our second issue. This code is perfectly valid and will compile, but users will get a string input for their height instead of a number picker.

formFields: FormlyFieldConfig = [
  // BUG: Wrong form control, should be fb.number('height')
  fb.input('height'),
];

In essence, we want to express the fact that, for the number properties of our model, we want the number control to be used. Similarly, we only want to use date pickers for the date properties of our model.

This is possible with the type FormlyKeyValue. (We will break down this type later in this shot to explain how it works).

export type FormlyKeyValue<TModel, ControlType> = {
	[K in keyof TModel]: 
		TModel[K] extends ControlType | null | undefined 
  			? K & string 
  			: never;
}[keyof TModel];

The generic type FormlyKeyValue takes two parameters. The first (TModel) is the form model so, in our case, this will be Person. The second parameter is the type of form control we want to limit the builder function to. If we set ControlType to a number, then we are expressing that we only want the number properties to be valid key arguments.

class FormlyFieldBuilder<TModel> {
  input(
    key: FormlyKeyValue<TModel, string>,
    configOverrides?: FormlyFieldConfig
  ): FormlyFieldConfig {}
  
  number(
    key: FormlyKeyValue<TModel, number>,
    configOverrides?: FormlyFieldConfig
  ): FormlyFieldConfig {}
  
  date(
    key: FormlyKeyValue<TModel, Date>,
    configOverrides?: FormlyFieldConfig
  ): FormlyFieldConfig {}
}

With this updated builder, we can now catch our second bug. Another benefit is that our autocomplete is more concise. So, when you write fb.number, the only suggestion will be height, as that is the only number type on our Person interface.

formFields: FormlyFieldConfig = [
  // ERROR: Argument of type '"height"' is not assignable 
  // to parameter of type 'FormlyKeyValue<FormModel, string>'
  fb.input("height"),
];

Constructing the FormlyKeyValue type

export type FormlyKeyValue<TModel, ControlType> = {
	[K in keyof TModel]: 
		TModel[K] extends ControlType | null | undefined 
			? K & string
			: never;
}[keyof TModel];

A good way to understand the FormlyKeyValue type is to experiment with it in this TS Playground, where we walk through the construction of its type definition. The following Typescript constructs are used in the type. Below are the links to the relevant Typescript documentation for each:

Base mapped type

Let’s start with the following type as the initial building block for FormlyKeyValue.

type ModelType<TModel> = {
  [K in keyof TModel]: TModel[K];
};

Here, we have a generic type that accepts one parameter, which, in our case, will be Person. We use the Mapped type syntax of [K in keyof TModel] to say "for every key in TModel" give it the type TModel[K]. We are using Indexed Access here to select the type for the given key from our TModel.

interface Person {
  name: string;
  dob: Date;
  height: number;
}

// types are equivalent PersonCopy ~ Person
type PersonCopy = ModelType<Person>;

With this type, we have created a one-to-one mapping of the input type. For each key on Person, (name, dob, height) we assign it a type. This is found by looking up that key on the original model.

interface Person {
  name: string;
  dob: Date
  height: number;
}

// If we expand the mapped type definition we see why this is copy of the original
type PersonCopy = ModelType<Person> =  {
    [name]: Person[name];
    [dob]: Person[dob];
    [height]: Person[height];
}

Flatten model keys

As we want a list of viable model keys, we need to flatten our model. We can do this by applying another mapping type of [keyof TModel] to the end of our type.

type ModelType<TModel> = {
  [K in keyof TModel]: TModel[K];
}[keyof TModel

This small addition has the effect of flattening our type, so that now ModelType<Person> becomes…

type PersonKeys = ModelType<Person> = 'string' | 'Date' | 'number';

(This is equivalent to keyof, currently, as each property is just mapped to its own type. That will now change.)

Restrict model keys based on the control type

The next part of the type depends on conditional typing. Conditional types enable us to encode statements like, “If a given property is a boolean, then give that property the typed string; otherwise, make it a number”. With our type, we wanted to say: “If the model property type matches the type of the given form control, then include it; otherwise, exclude it”. We represent this as:

[K in keyof TModel]: TModel[K] extends ControlType ? K : never;

It reads: for the key K on our TModel, if the type of the property at TModel[K] extends/matches that of our ControlType, give it the type K; otherwise, never give it this type.

export type FormlyKeyValue<TModel, ControlType> = {
  [K in keyof TModel]: 
    TModel[K] extends ControlType
      ? K 
      : never;
}[keyof TModel];

type PersonStringTypes = FormlyKeyValue<Person, string> = 'name';
type PersonNumberTypes = FormlyKeyValue<Person, number> = 'height';
type PersonDateTypes = FormlyKeyValue<Person, Date> = 'dob';

We have created a type that extracts all the keys from our form model that match the corresponding control-type, as required to solve our issues above.

Final tweaks

As a final touch, we swap ControlType with ControlType | null | undefined to enable strict mode to handle optional form properties. We also set the mapped type to K & string as opposed to just K. This is because the key property for the underlying FormlyFieldConfig expects a string, so we enforce this on our model.

If you have numeric keys you could use Template Literal Types to convert them to strings as required.

We now have the type required to set up our FormlyFieldBuilder so that it ensures a given key is a valid property of the form model, and its type matches that of the control being used in the form.

export type FormlyKeyValue<TModel, ControlType> = {
  [K in keyof TModel]:
    TModel[K] extends ControlType | null | undefined
      ? K & string
      : never;
}[keyof TModel];

class FormlyFieldBuilder<TModel> {
  input(
    key: FormlyKeyValue<TModel, string>,
    configOverrides?: FormlyFieldConfig
  ): FormlyFieldConfig {}
}


interface Person {
  name: string;
  dob: Date;
  height: number;
}

const fb = new FormlyFieldBuilder<Person>;
fb.input('name');
fb.input('height'); // ERROR: height is a number not a string
fb.input('surname'); // ERROR: surname is not a member of Person

Since this type has proved so successful in our applications, I have created a PR to see if it would be possible to have this included with Formly itself.

RELATED TAGS

angular
community creator
RELATED COURSES

View all Courses

Keep Exploring