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

When building grids that share recurring configuration patterns, AG Grid lets you bundle common settings into named columnTypes. A type is nothing more than an object whose properties become part of any column that references it. You apply a type simply by naming it in the column definition.

Following the official documentation, a typical setup might look like this:

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

These declarations enable usage such as:

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

Once your project grows to include many grids, you will naturally start extracting repeated column configurations into dedicated types. The number of these types can swell quickly as you cater to varied column scenarios. At this point, two recurring problems tend to surface.

Recognized Challenges

1) Making custom types discoverable across the team

In a shared codebase, it takes effort to keep everyone aware of the available custom column types. You might document them or centralize them in a common file, but such information is easy to overlook. Developers may search for existing solutions and still end up creating duplicate definitions, simply because they weren't aware of what already exists.

2) Preventing typos that silently corrupt the grid

A typo in a column type name is hard to catch, since any string is a valid value for type. The build will pass without errors, yet the grid's behavior may be altered. Unless a reviewer or test happens to notice the issue, the defect can easily reach production.

Addressing Both Problems

Both issues can be eliminated by placing a stricter TypeScript interface on top of the existing ColDef type. The type property in the original interface is declared as string | string[]. One way to narrow this is to extend ColDef while overriding the type property with a union type of all supported values.

type SupportedColTypes = 'dateColumn' | 'nonEditableColumn';

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

Here, SupportedColTypes enumerates every column type your application can use. In your own column definitions, you then swap ColDef for AppColDef.

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

This small change resolves the two issues. For discoverability, the IDE now offers autocompletion of the full list of types, so every developer sees all options directly in their editor. There’s no longer a need to jump between files to recall what’s available. If a new column type is added and put into SupportedColTypes, it is immediately visible to everyone.

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

For the typo problem, an invalid column type now becomes a compile-time error. What used to surface only after deployment is now caught in the editor, allowing immediate correction during development.

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

Keeping SupportedColTypes in Sync

Once we have this strict union, we must make sure it always reflects the actual set of implemented column types. Using Mapped Types, we can force the compiler to verify this consistency.

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

If you define an additional column type in APP_COL_TYPES but forget to add its name to SupportedColTypes, the compiler will raise an error. The constraint ensures every property of APP_COL_TYPES is also a key of the union.

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

The reverse is also protected. If you extend SupportedColTypes without supplying the corresponding implementation in APP_COL_TYPES, TypeScript will flag the oversight, because the typing expects every key to be present on the object.

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

Trade-offs to Remember

While the stricter typing brings clear advantages, it also removes some flexibility. If you need to add a one-off column type for a single grid, you’ll have to work around the restrictions. A quick as any can sidestep the type, or you can define a more permissive interface such as ExtraSupportedTypes extends SupportedColTypes. Either way, that scenario adds extra code.

In practice, the added rigidity has been well worth it. Team members unfamiliar with the internal AG Grid configuration appreciate having dropdown suggestions of available types, allowing them to apply consistent styling and behavior without needing to learn every grid property by heart.

Formly is a reliable way to build forms in Angular. The idea is to describe the form structure in TypeScript, then let a Formly component render the appropriate controls. Similar to AG Grid’s columnType, the FormlyFieldConfig has a type field that chooses which control is displayed.

To illustrate, imagine a form collecting a person’s name, date of birth, and height. The corresponding Formly config might render like this:

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 a template set up for rendering, you get a form without writing the HTML for each field.

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

The setup is clear at first, but as the number of forms and fields increases, the approach begins to fray. Repeated configuration blocks become prime candidates for copy‑and‑paste mistakes.

Where Bugs Tend to Hide

1) An incorrect key that points nowhere

The key property is typed simply as string within FormlyFieldConfig. A typo or an old key left over after a model refactor is easy to introduce. Formly will still build and render the form, but the input value lands in the wrong spot on the model. Your code may be looking for the data elsewhere, and in the worst case, submitting the form could wipe out user input.

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

Since key accepts any string, the project compiles without complaint. Yet the form is wrong: dob is not the same as dateOfBirth.

2) A control type that mismatches the model property

There is also nothing to stop you from pairing a property with an incompatible form control. For instance, using a text input for a date field.

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

No typing relationship exists between type and key, so the build succeeds while the UI is incorrect. Users see a text box where a date picker should appear.

Building a Typed Config Builder

The answer is to wrap the raw FormlyFieldConfig in our own builder functions. These will carry stricter typing, ensuring that the key and type match the model. As a bonus, the builders cut down on boilerplate.

The first step is to encapsulate the logic for each control type. For text inputs, numeric inputs, and date pickers, we set up a builder that also applies a default label to save keystrokes.

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) {}
}

Our form definition now looks like this:

const fb = new FormlyFieldBuilder();

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

We eliminated a good deal of duplication, but the two bugs can still occur. The following code would compile and run, despite being wrong.

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

Fixing Bug 1: Constraining key to the Model

Our first fix is to make the builder generic. By using keyof, we can allow only those property names that actually exist on the 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, that same incorrect code is rejected by the compiler.

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 catches typos and ensures that renamed model properties are reflected everywhere. Another benefit is that your editor will now suggest valid key values as you type, which speeds up development.

Fixing Bug 2: Enforcing Control Type by Model Type

Even with the key restriction, the second issue remains. This code is still valid and will compile, but inputs a string for a number property.

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

The goal is to correlate the model property type with the control type: numeric fields should use the number control, and date fields should use the date control.

The FormlyKeyValue type enables this. A breakdown of its internals appears later in the article.

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

The generic type takes two arguments. The first, TModel, is the shape of the form’s model—in this case Person. The second, ControlType, limits which properties are valid keys. When set to number, only number properties of the model are allowed.

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, the mismatched example no longer compiles. It also sharpens autocompletion: typing fb.number offers only height as a suggestion, since that’s the sole number property on the Person interface.

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

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

Building the FormlyKeyValue Type

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

A practical way to grasp the FormlyKeyValue type is to try it out in this TS Playground where the definition is assembled step by step. The type relies on several TypeScript constructs, and links to the official documentation for each are provided below.

Foundation: A Simple Mapped Type

We begin with a straightforward type that serves as the starting point for FormlyKeyValue.

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

This is a generic type accepting a single parameter—in our scenario, that will be Person. Through the mapped type syntax [K in keyof TModel], we iterate over every key in TModel, assigning each one the type TModel[K]. The indexed access here allows us to pull the specific type associated with each key from the original model.

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

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

The result is a one-to-one mapping of the input type. Each property on Personname, dob, height—receives the type derived from that same key on the source 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];
}

Flattening Model Keys

Since we need a flat list of all eligible model keys, we introduce another mapped type, [keyof TModel], appended to the end of the type definition.

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

This small adjustment collapses the type structure, so ModelType<Person> now resolves to:

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

(At this stage, the type is effectively equivalent to keyof, since each property maps directly to its own type—but that is about to change.)

Filtering Keys by Control Type

The following stage hinges on conditional types. These let us express logic along the lines of, "If a property holds a boolean, assign it a string type; otherwise, fall back to number." For our purposes, we state: "If the model property's type aligns with the type expected by the form control, keep that key; otherwise, discard it." This is encoded as:

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

In plain terms: for each key K in TModel, if the property type TModel[K] satisfies (extends) the ControlType, the key keeps its type K; if not, it becomes 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';

The outcome is a type that collects all keys from the form model whose underlying types correspond to the given control type—exactly what we need to address the issues mentioned earlier.

Final Adjustments

As a finishing step, we replace ControlType with ControlType | null | undefined, accommodating optional model properties under strict mode. Additionally, the mapped type uses K & string rather than plain K. This is because the key property on the underlying FormlyFieldConfig expects a string, so we enforce that constraint on our model. (If your model uses numeric keys, Template Literal Types can convert them to strings as needed.)

We now possess the type necessary for our FormlyFieldBuilder to guarantee that any provided key is a legitimate property of the form model, and that its type aligns with the control configured 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

Given how effective this type has proven across our projects, I have opened a PR to explore incorporating it directly into Formly.

Conclusion

Third-party libraries often avoid excessively strict typings to accommodate a broad spectrum of use cases. That limitation, though, does not stop us from introducing our own layered types or custom wrappers tailored to our specific needs, significantly enhancing the developer experience through better type safety.