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

Post Editor

Third party library interfaces are often loosely typed to support edge cases. However, there can be great benefits to layering more explicit types over the existing interfaces.

12 min read
0 comments
post

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

Third party library interfaces are often loosely typed to support edge cases. However, there can be great benefits to layering more explicit types over the existing interfaces.

post
post
12 min read
0 comments
0 comments

You may have noticed that the typing provided by 3rd party libraries often feels very loose. This should not come as a surprise as 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 consistently 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
Link to this section

A foundational concept of AG Grid are 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 3 columns defined for the grid with different behavior:

<>Copy
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:

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

These would be used as follows:

<>Copy
this.columnDefs: ColDef[] = [ { field: 'favouriteDate', type: 'dateColumn' }, { field: 'restrictedDate', type: ['dateColumn', 'nonEditableColumn'] } ];
<>Copy
<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
Link to this section

1) Sharing knowledge of existing and new column types
Link to this section

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?
Link to this section

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)
Link to this section

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.

<>Copy
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.

<>Copy
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.

Content imageContent image

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!

Content imageContent image

Implementing SupportedColTypes Safely
Link to this section

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.

<>Copy
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.

Content imageContent image

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.

Content imageContent image

Considerations
Link to this section

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:

<>Copy
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.

<>Copy
<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
Link to this section

1) key does not match a valid model property
Link to this section

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!

<>Copy
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
Link to this section

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.

<>Copy
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
Link to this section

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.

<>Copy
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:

<>Copy
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.

<>Copy
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
Link to this section

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.

<>Copy
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.

<>Copy
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
Link to this section

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.

<>Copy
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.)

<>Copy
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.

<>Copy
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.

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

Constructing the FormlyKeyValue type
Link to this section

<>Copy
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
Link to this section

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

<>Copy
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.

<>Copy
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.

<>Copy
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
Link to this section

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.

<>Copy
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:

<>Copy
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
Link to this section

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:

<>Copy
[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.

<>Copy
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
Link to this section

As 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.

<>Copy
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
Link to this section

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 greatly improve the developer experience due to enhanced typing.

Comments (0)

Be the first to leave a comment

Share

About the author

author_image

Stephen is a Senior Engineer at AG Grid building out the Javascript grid. When not coding you will find him out and about with his four little explorers.

author_image

About the author

Stephen Cooper

Stephen is a Senior Engineer at AG Grid building out the Javascript grid. When not coding you will find him out and about with his four little explorers.

About the author

author_image

Stephen is a Senior Engineer at AG Grid building out the Javascript grid. When not coding you will find him out and about with his four little explorers.

Featured articles