Does Angular Support Generic Component Types?
Stephen Cooper
Posted on December 20, 2022
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.
However, with our updated typings in the latest versions of AG Grid, this same code will result in a compile error as expected.
We now also can see how the generic parameter has been correctly inferred along with improved IDE error highlighting.
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.
Posted on December 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 20, 2024
November 15, 2024