Generic base components in Angular

December 10th 2021 Angular TypeScript

Type checking in Angular is constantly improving. In newer versions, it even does a good job in templates. This can help us detect errors in our code earlier. However, sometimes extra work is needed to take full advantage of this feature.

Rendering derived types

For example, consider a component for rendering a list of items that extend the same base type:

@Component({
  selector: "app-item-list",
  templateUrl: "./item-list.component.html",
  styleUrls: ["./item-list.component.scss"],
})
export class ItemListComponent {
  @Input()
  items: Item[] = [];
}

It can be used with any number of types that extend the Item type:

export interface Item {
  label: string;
}

export interface ItemA extends Item {
  valueA: number;
}

export interface ItemB extends Item {
  valueB: string;
}

The component (and its template) knows nothing about the derived types. It can only access the members of the Item base type:

<div *ngFor="let item of items">
  <div>{{ item.label }}</div>
</div>

The component is nevertheless perfectly suited for rendering all derived types:

<app-item-list [items]="itemsA"></app-item-list>
<hr />
<app-item-list [items]="itemsB"></app-item-list>

Emitting derived types

Things get a bit more complicated when the component also needs to emit one of the rendered items (when it is clicked, for example). The EventEmitter declaration must use the same base type:

@Output()
itemClick: EventEmitter<Item> = new EventEmitter<Item>();

The value can then be emitted directly from the template:

<div *ngFor="let item of items">
  <div (click)="itemClick.emit(item)">{{ item.label }}</div>
</div>

The component's consumer can now add a handler for the newly created event:

<app-item-list
  [items]="itemsA"
  (itemClick)="itemClickA($event)"
></app-item-list>

However, this will not work as expected if the handler argument is declared with the derived type:

itemA?: ItemA;

itemClickA(item: ItemA) {
  this.itemA = item;
}

The compiler reports the following error:

Argument of type 'Item' is not assignable to parameter of type 'ItemA'. Property 'valueA' is missing in type 'Item' but required in type 'ItemA'.

Although we know that the itemsA variable contains only instances of ItemA and the itemClick event emits only one of those instances, the compiler does not know that. Hence the error.

We can work around the problem by changing the event handler:

itemClickA(item: Item) {
  this.itemA = item as ItemA;
}

This fixes the compiler error, but also reduces type safety. We tell the compiler that the parameter is actually an instance of ItemA, and therefore can be safely assigned to a field of the same type. But that may not be the case.

We should improve the type declarations for the component so that the compiler can check this.

Generics to the rescue

TypeScript has means to do this: generics. We can tell the compiler that both the list and the event have the same type, which extends the Item base type:

export class ItemListComponent<T extends Item> {
  @Input()
  items: T[] = [];

  @Output()
  itemClick: EventEmitter<T> = new EventEmitter<T>();
}

Unfortunately, there is no way in Angular to create an instance of a generic component. To get around this limitation, we need to create a non-generic component for each derived type we want to use it with:

@Component({
  template: "",
})
export abstract class ItemListComponent<T extends Item> {
  @Input()
  items: T[] = [];

  @Output()
  itemClick: EventEmitter<T> = new EventEmitter<T>();
}

@Component({
  selector: "app-itema-list",
  templateUrl: "./item-list.component.html",
  styleUrls: ["./item-list.component.scss"],
})
export class ItemAListComponent extends ItemListComponent<ItemA> {}

@Component({
  selector: "app-itemb-list",
  templateUrl: "./item-list.component.html",
  styleUrls: ["./item-list.component.scss"],
})
export class ItemBListComponent extends ItemListComponent<ItemB> {}

I marked the base generic component as abstract to explicitly state that it cannot be instantiated. Nevertheless, Angular requires the @Component decorator if the Angular features are to work (e.g. inputs, outputs, dependency injection for constructor arguments...). The decorator requires a template, so I specified an empty string to avoid compilation errors.

The two derived components specify the concrete derived type, so the class is no longer generic. You can reuse the same template and styles (but you do not have to if you do not want to). Only the selector needs to be different so we can choose which component we want to consume:

<app-itema-list
  [items]="itemsA"
  (itemClick)="itemClickA($event)"
></app-itema-list>
<hr />
<app-itemb-list
  [items]="itemsB"
  (itemClick)="itemClickB($event)"
></app-itemb-list>

The compiler can now fully check the input and output types of each component. It detects when the bound list of items does not have the right type. And it allows the event handler to correctly specify the expected argument type:

itemClickA(item: ItemA) {
  this.itemA = item;
}

A full working example of this approach can be found in my GitHub repository.

In TypeScript, generics can be used to better describe the types in classes that are reusable with multiple derived types. Angular does not support instantiation of generic components, but this limitation can be worked around by creating a derived component for each derived type we want to use it with. This requires some extra code, but we are rewarded with better type safety.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License