Does Angular Support Generic Component Types?
Tags: angular, typescript
Published: 2022-12-20 16:49:14

You may be familiar with writing [Typescript Generics](https://www.typescriptlang.org/docs/handbook/2/generics.html) 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](https://www.typescriptlang.org/docs/handbook/2/generics.html) 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.

```ts
@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.

```ts
@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`.

```ts
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. 

```ts
@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`.

```ts
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.

```html
<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.

```html
<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.

```ts
@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.

```ts
carData: ICar[];
```

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

```html
<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](https://ag-grid.com/). This component is generic with respect to row data. It is defined in the following way with many properties omitted for brevity. 

```ts
@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`.

```ts
carData: ICar[];
defaultColDef: ColDef;
```

```html
<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:

```ts
interface ColDef<TData = any>{}
```

You would define the interface as:

```ts
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.

```ts
  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: 

```diff
- 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.

```diff
- @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. 

```ts
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:

```html
<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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/reor2w9q6lldhxxjnfyd.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3dnojez0iok2tw47zz31.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oraowaj51tg3xi9b7rwl.png)

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](https://twitter.com/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](https://github.com/angular/angular/blob/main/packages/common/src/directives/ng_for_of.ts#L18). Big thanks to Alex!


---

Stephen Cooper - Senior Developer at [AG Grid](https://ag-grid.com/)
Twitter [@ScooperDev](https://twitter.com/SCooperDev) or [Tweet](https://twitter.com/intent/tweet?text=%22Does%20Angular%20Support%20Generic%20Component%20Types%3F%22%20by%20%40SCooperDev%20%23DEVCommunity%20https%3A%2F%2Fdev.to%2Fangular%2Fdoes-angular-support-generic-component-types-4fkm) about this post.