Software Engineering 08/06/2021

Adding a layer of more explicit typings on top of third party library interfaces

Written by Stephen Cooper, a Developer at G-Research

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

However, there is nothing to stop us, within our own applications, adding a layer of more explicit typing on top of the library interface. In doing so we can leverage Typescript to greatly improve the developer experience, as well as encouraging consistency in how the library is used.

To demonstrate the benefits of this approach we will work through two use cases:

  • Adding type safety to AG Grid column types
  • Generating type safe custom builders for the Angular Formly library

With both of these scenarios we will show how adding an additional level of explicit types prevents bugs and speeds up development.

AG Grid: Typing Column Types

A foundational concept of AG Grid is the column definitions. The column definitions govern all aspects of how a column appears, behaves and interacts with the grid. Here’s an example of three columns defined for the grid with different behaviour:

const gridOptions = {
  // define grid columns
  columnDefs: [
    { headerName: "Athlete", field: "athlete", rowGroup: true },
    { headerName: "Sport", field: "sport", filter: false },
    { headerName: "Age", field: "age", sortable: false },
  ],
  // other grid options ...
};

As we often want to apply similar sets of behaviour to a column we can set up predefined columnTypes to avoid explicitly repeating all the required properties. A column type is an object containing properties that other column definitions can inherit. Once column types are defined you can apply them by referencing their name.

So for example you could define the following types as per the AG Grid documentation:

this.columnTypes = {
  nonEditableColumn: { editable: false },
  dateColumn: {
    filter: "agDateColumnFilter",
    filterParams: { comparator: myDateComparator },
    suppressMenu: true,
  },
};

These would be used as follows:

this.columnDefs: ColDef[] = [
  { field: 'favouriteDate', type: 'dateColumn' },
  { field: 'restrictedDate', type: ['dateColumn', 'nonEditableColumn'] }
];
<ag-grid-angular
  [columnDefs]="columnDefs"
  [columnTypes]="columnTypes" />
</ag-grid-angular>

When working with projects that contain many grids you may find that you are repeatedly setting up common column definitions. To avoid repeating code you will want to start extracting these into column types. You can quickly end up with a large number of column types for many different column scenarios. This is when you might find you run into the following problems.

Potential Issues

1) Sharing knowledge of existing and new column types

When you have a team working on a shared project it can be difficult to share the knowledge and existence of custom column types. They would have to be documented or at least visible via a shared code file. However, it is very easy to forget about the existence of these types and miss any new additions. Relying on developers to check the contents of a shared file is not a great developer experience and could potentially lead to developers re-inventing the wheel.

2) How do you catch typos in type names that will break your grid?

It is very easy to mistype a name when setting up a column definition as any string is valid. The application will build successfully but a core feature of the grid might be broken. Without very careful code reviews or another testing process this bug could slip into production.

Solving Potential Issues 1) and 2)

We can solve both of these issues by layering a Typescript interface on top of the AG Grid ColDef. Currently the type property of ColDef is defined as string | string[] . One potential implementation is to extend the ColDef interface but restrict the type property to a new union type of SupportedColTypes.

type SupportedColTypes = "dateColumn" | "nonEditableColumn";

interface AppColDef extends ColDef {
  type?: SupportedColTypes | SupportedColTypes[];
}

SupportedColTypes will define all possible column types that we support. Then when defining our column definitions in our app we replace ColDef with AppColDef.

this.columnDefs: AppColDef[] = [
  { field: 'favouriteDate', type: 'dateColumn' },
  { field: 'restrictedDate', type: ['dateColumn', 'nonEditableColumn'] }
];

With this change we solve the two issues we had above. For the first, as there is now a defined list of types, our IDE’s can leverage Typescript and provide auto completion. This means that every developer has the complete list of supported types at their fingertips while setting up the column definitions. No context switching to another file to look up column types anymore. Additionally, if a developer adds a new column type, as long as this is included in SupportedColTypes then this will be easily discoverable by the rest of the team.

Auto complete for type property

For the second issue we have turned typos in column types into compile errors. This is hugely important as we can instantly correct mistakes during dev time and not in production!

Error on not supported types

Implementing SupportedColTypes Safely

It is now critical that SupportedColTypes remains consistent with the actual implementations of the shared columnTypes we have defined. We can leverage Typescript to ensure this is the case by using Mapped Types.

APP_COL_TYPES: { [key in SupportedColTypes]: ColDef };

Now if you add another column type to APP_COL_TYPES the compiler will complain if the key name is missing from SupportedColTypes as every property in APP_COL_TYPES has to be a key of SupportedColTypes.

Conversely, if you add a new column type to SupportedColTypes but forget to add its implementation, then Typescript will flag it. This is because every key from SupportedColTypes must be present on the object according to our typing.

Considerations

While this approach has great benefits we should mention that it comes at a cost of some flexibility. Say you want to add extra column types to a single grid you will now need to work around the restrictive typing of SupportedColTypes. This can be done with as any or taking the time to define an extended type such as ExtraSupportedTypes extends SupportedColTypes and apply this to your column definitions. As you can imagine this does add a level of extra boiler plate for this case.

However, in practice, my team has found this restrictive typing very helpful. This is especially true for the developers who are not so familiar with the shared AG Grid setup. They have loved having auto completion for the column type to instantly apply styling and behaviour to their column definitions without having to understand all the grid properties themselves.

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. In a similar way to 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 to 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) 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 clearing 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 but 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 an 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: { ... }
  }
];

As there is no typing link between the type and key properties the build will succeed but 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 also have the added benefit of dramatically reducing the amount of boiler plate 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"),
];

Although we have reduced our boilerplate code, 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"),
];

Solve 1) 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 of our model key properties which makes setting these fields up significantly easier.

Solve 2) Control type does not match model property type

Even with the above change, we have not solved our second issue yet. 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 number properties of our model we want the number control to be used. Similarly, we only want to use date pickers for date properties of our model.

This is possible with the type FormlyKeyValue. (We will breakdown this type later in this article 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 the form control we want to limit this builder function to. If we set ControlType to number 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 now catch the second of our bugs. Another benefit is that our autocomplete is more concise. Now as 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"),
];

Auto complete for valid types

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 and here 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, in our case this 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 the type 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 but 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 type 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 then give it the type K otherwise never.

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

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

Conclusion

Many third party libraries are not able to provide restrictive typings due to the wide range of use cases that they must support. However, that does not mean that we cannot layer our own custom types or use custom wrappers, tailored to our own applications, to improve the developer experience greatly through enhanced typing.

Stay up to-date with G-Research

Subscribe to our newsletter to receive news & updates

You can click here to read our privacy policy. You can unsubscribe at anytime.