Dec 20 '22 ~ 53 ~ 9 mins

Does Angular Support Generic Component Types?

You may be familiar with writing Typescript Generics like Grid<IRowData> but how does this work for Angular components? We can't provide generic types to our template selectors so how does Angular support generic components?

In this article we will explain how Angular does it, but also how you can help the Angular Compiler be more accurate in complex components.

Typescript Generics

Typescript Generics are a very powerful feature. Through generics we are able to customise existing Interfaces to a specific use case. For example, you might have a grid component to display rows of data. By making the grid component generic users can provide their own interface that represents their row data and have that interface used throughout the grid components' properties.

Why do we want a Generic Component?

Before we dive into the technical details of generic components I think it's important that we understand why we want to have generic support for our components.

Let's say we have a grid component that displays items and fires an event when users select a row. Without Generics we have to specify the type of the row data as any because we want to re-use this grid component with different data sets.

@Component({
    selector: 'app-grid',
})
export class GridComponent {
  
    @Input() rowData?: any[];
    @Output() onSelection: EventEmitter<any> = new EventEmitter<any>();
}

We would use this GridComponent like so in our application.

@Component({
    selector: 'app-car-data',
    template: `<app-grid [rowData]="rowData" (onSelection)="onRowSelected($event)"></app-grid>`
})
export class CarDataComponent {
    // Row data of cars to provide to the grid
    rowData: ICar[];
    // Callback when a row is selected
    function onRowSelected(event: ICar){
    }
}

This all works as expected but what if we made a mistake in our code and defined the selection callback with an event of type IPerson instead of ICar.

function onRowSelected(event: IPerson){
  // ERROR: Function definition expects IPerson but it will be called with ICar!
}

Our component does not complain as there is no link between the type of rowData and onRowSelected. Both IPerson and ICar are assignable to any so there is no error but we have a bug that might only be spotted at runtime.

However, if our component used generic types then Angular could warn us that the two properties do not match and we could fix the issue immediately.

Creating a Generic component

You make a component generic by providing a generic type parameter within angle brackets as part of the class declaration: GridComponent<TData>. You then use the generic type TData throughout your class where ever that type should be used.

For the GridComponent that looks like this.

@Component({...})
export class GridComponent<TData> {
  
    @Input() rowData?: TData[];
    @Output() onSelection: EventEmitter<TData> = new EventEmitter<TData>();
}

Note how we define TData as our generic type and then use that in place of the any type we previously had for rowData and the selection callback. This has the desired effect of linking the type of rowData with the onSelection EventEmitter.

Using a Generic Component

If we were writing plain Typescript we would create the GridComponent component and apply the generic type of ICar as new GridComponent<ICar>. Then all the generic properties would use the ICar type in place of TData.

const myGrid = new GridComponent<ICar>()

// type is ICar[]
myGrid.rowData;
// type is EventEmitter<ICar>
myGrid.onSelection;

However, this is not how we generally create components in Angular. Instead we place the selector in our template and set the Input and Outputs.

<app-grid 
  [rowData]="carData" 
  (onSelection)="carSelected($event)" >
</app-grid>

The key point is this: unlike JSX, you cannot explicitly provide the generic type to the component selector!

For example, the following is not valid HTML code.

<app-grid<ICar> [rowData]="carData" ></app-grid>

You cannot provide generic types to an Angular component explicitly, but that doesn't stop Angular supporting generic components via another mechanism.

So how do generic components work?

As we cannot specify the generic type explicitly, Angular has to infer it from the types of the Input and Outputs that we bind to the component. Let's step through this to explain how it works.

Firstly, the component author specifies the generic type, TData, and uses it for one or more properties.

@Component({...})
export class GridComponent<TData> {
  
    @Input() rowData?: TData[];
    @Output() onSelection: EventEmitter<TData> = new EventEmitter<TData>();
}

The user of the component types their properties in the app component.

carData: ICar[];

They then bind these to the component via an Input, in this case rowData.

<app-grid [rowData]="carData">

At this point the Angular compiler knows that the property carData has the type ICar[] and this has been assigned to the Input rowData which has the type TData[]. It then infers that the generic type TData should be set to ICar.

Using this information the compiler then applies the correct type to the onSelection Output. That type then becomes EventEmitter<ICar> and the compiler can validate that the types of rowData and onSelection are consistent.

Now if we make a mistake in the configuration, and provide incompatible types to multiple generic properties, we will get a build error. This is great and just what we wanted!

You may now be wondering why we said that Angular doesn't support generic types fully. It certainly seems like it does from this example.

Impact of Inference

In most cases when working with Generic components in Angular everything will work as expected. However, if the component gets more complex, in its use of the generic type parameter, then you may start to see a breakdown / widening in type inference.

This happens because type inference in Typescript has to ensure it is accurate across every code path. When Typescript cannot narrow the type specified in every code path you may see that the generic type is inferred as any. At this point you will stop getting template type errors because any matches any type.

In our example above this means the error about providing IPerson to the selection event would just disappear.

In these situations we need to start applying new techniques to help improve the developer experience with generics in Angular. We will do this by tweaking how we define our component's generic types.

Example Breakdown of Inference

To give a concrete example of the breakdown in inference we can look at the ag-grid-angular component from AG Grid. This component is generic with respect to row data. It is defined in the following way with many properties omitted for brevity.

@Component({
    selector: 'ag-grid-angular',
})
export class AgGridAngular<TData = any> {
  
    @Input() rowData?: TData[];
    @Input() columnDefs?: ColDef<TData>[];
    @Input() defaultColDef?: ColDef<TData>;
    @Output() rowSelected: EventEmitter<RowSelectedEvent<TData>> = new EventEmitter<RowSelectedEvent<TData>>();
}

If you write the following code with the ag-grid-angular component you might expect that the generic type would be correctly inferred as ICar.

carData: ICar[];
defaultColDef: ColDef;
<ag-grid-angular
 [rowData]="carData"
 [defaultColDef]="defaultColDef" >
</ag-grid-angular>

However, it is not. Instead you will see that the generic type is any. This means that there will be no template type checking across properties.

After a bit of investigation it is possible to find the cause of the issue. When defining the defaultColDef the type used is ColDef with no generic type instead of ColDef<ICar>. This means that the default generic type of any is used. This any is one of the possible types for the TData generic type in the component preventing further narrowing of the TData type to ICar.

Enforce Generic Parameters

One solution to ensure that generic types are correctly inferred is to enforce that your users supply them to every interface.

This can be enforced by not providing a default type. So instead of:

interface ColDef<TData = any>{}

You would define the interface as:

interface ColDef<TData>{}

There are tradeoffs with this approach though. For AG Grid this would have resulted in a major breaking change for all Typescript users. They suddenly would have been forced to update every type declaration to include a generic property. We did not feel this would be well received.

If you are in the different position of creating a new component from scratch, then maybe you could consider not supplying a default type as this will lead to more accurate and consistent type inference in the long run.

But what if you need to provide a default any type?

Understanding Code Inference in Angular

At this point we need to take a deeper look at how inference for Angular components works. It turns out that it is similar to the following static code structure.

  static typeCtor<TData>(inputs: Partial<Pick<GridComponent<TData>, 'rowData' | 'onSelection'>>): GridComponent<TData> {
    return null!;
  }

This gives us a useful mechanism for experimenting with different generic setups without the need to run the Angular compiler over our component in a template. This means you could validate inference for a component in an easily shareable environment like TS Playground.

You can use this approach to experiment with your generic types to find those that give the best developer experience based on common use cases.

Influencing Type Inference

This next section covers the specific changes made to the AG Grid component to improve its generic type support. We are trying to prevent the use of the ColDef interface with no type parameters from leading to a breakdown in inference of the TData generic type.

The way we do this is to introduce a second generic parameter, TColDef that is derived from the first: TColDef extends ColDef<TData>. This is entirely for the purpose of changing how inference works and has no other implications.

Our component definition changes like this:

- class AgGridAngular<TData = any>
+ class AgGridAngular<TData = any, TColDef extends ColDef<TData> = ColDef<any>>

Then we update our columnDefs Input to use this new derived generic parameter.

- @Input() columnDefs?: ColDef<TData>[];
+ @Input() columnDefs?: TColDef[];

We provide a default value of ColDef<any> to TColDef, so that we do not changed the interface requirements of AgGridAngular in case the component is used in a ViewChild selector.

However, as most users will only define the component in their template with ag-grid-angular this change will likely be invisible to them. They do not have to worry about the extra generic type parameter as they never specified them in the first place.

On the surface you may not expect this to change anything as you are simply redefining the columnDefs type as part of the generic parameters. We haven't actually changed any types here. However, this relocation has the effect of separating which properties Typescript tries to infer to the same type. The result for the AgGridAngular component is that the columnDefs property has a stronger influence on the inferred type of TData.

Now let's show the impact of this change in real application code.

Improved Inference Results

To see the result of this change we will show the exact same code with two different versions of AG Grid. (As this is all about the types I have omitted the actual implementation and just show the typings)

We define our properties and provide the ICar interface as the generic parameter to columnDefs and rowData. We don't provide a generic parameter to defaultColDef.

Finally, we simulate an error by setting the generic parameter to IPerson for the onRowSelected callback.

columnDefs: ColDef<ICar>[];
defaultColDef: ColDef;
rowData$: Observable<ICar[]>;
//This should result in an error as IPerson != ICar
onRowSelected(e: RowSelectedEvent<IPerson>): void {}

We then assign these to the component as follows:

<ag-grid-angular
  [columnDefs]="columnDefs" 
  [defaultColDef]="defaultColDef"
  [rowData]="rowData$ | async"
  (rowSelected)="onRowSelected($event)">
</ag-grid-angular>

If you were to use AG Grid v28.0 this code would compile as the generic parameter fallsback to any as can be seen in this screenshot.

IDE Code showing generic type inferred as any

However, with our updated typings in the latest versions of AG Grid, this same code will result in a compile error as expected.

Build error: ICar and IPerson not compatible

We now also can see how the generic parameter has been correctly inferred along with improved IDE error highlighting.

IDE Code showing correct inference of TData generic type

This is great news because it means we have been able to improve the typing experience for AG Grid users without forcing them to add generic parameters to all their existing code.

Conclusion

While in many cases Angular will correctly infer your component types, when it does not, I hope that by sharing this knowledge you will be in a stronger position to provide hints to the Angular compiler to get it back on track.

Credits

My thanks go to Alex Rickabaugh @synalx from the Angular core team for showing me this approach in the Hallway track at NG Conf. It turns out this is also used to improve the typings for ngFor. See here. Big thanks to Alex!


Stephen Cooper - Senior Developer at AG Grid Twitter @ScooperDev or Tweet about this post.


Headshot of Stephen Cooper

Hi, I'm Stephen. I'm a senior software engineer at AG Grid. If you like my content then please follow / contact me on 🦋 Bluesky or 𝕏 (X) and say hello!