A generic component has a type parameter — DataTable<T>, Select<T>, List<T> — and works with any kind of data, keeping type safety in the template and in the outputs. Angular infers T from the input you bind (for example from [rows]), exactly like a generic function in TypeScript.
Example: DataTable<T>
The table handles the behaviour (sorting, selection, empty row), and whoever uses it provides the columns (typed configuration) and, optionally, templates for special cells.
export interface Column<T> {
key: Extract<keyof T, string>; // only real keys of T
header: string;
sortable?: boolean;
align?: 'left' | 'right';
format?: (row: T) => string;
}
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet],
template: `
<table>
<thead>
<tr>
@for (col of columns(); track col.key) { <th (click)="toggleSort(col.key)">{{ col.header }}</th> }
</tr>
</thead>
<tbody>
@for (row of sortedRows(); track trackBy()(row)) {
<tr (click)="rowClick.emit(row)">
@for (col of columns(); track col.key) {
<td>
@if (cellTemplates().get(col.key); as tpl) {
<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="{ $implicit: row, value: row[col.key] }" />
} @else {
{{ col.format ? col.format(row) : row[col.key] }}
}
</td>
}
</tr>
}
</tbody>
</table>
`,
})
export class DataTable<T> { // ← the component’s type parameter
readonly rows = input.required<readonly T[]>(); // ← T is inferred from here
readonly columns = input.required<readonly Column<T>[]>();
readonly trackBy = input<(row: T) => unknown>((row) => row);
readonly rowClick = output<T>(); // ← typed output
private readonly cellDirectives = contentChildren(CellTemplate<T>);
protected readonly cellTemplates = computed(
() => new Map(this.cellDirectives().map((d) => [d.appCell(), d.template] as const)),
);
protected readonly sort = signal<{ key: keyof T; dir: 'asc' | 'desc' } | null>(null);
protected readonly sortedRows = computed(() => { /* sorting by this.sort() */ });
}DataTable<UserDto>
| Ana Popescu | Cluj-Napoca | admin | 2021 |
| Mihai Ionescu | București | editor | 2022 |
| Ioana Dumitru | Iași | viewer | 2023 |
| Radu Stan | Timișoara | editor | 2020 |
| Elena Marin | Brașov | viewer | 2024 |
| Andrei Georgescu | Cluj-Napoca | admin | 2019 |
| Maria Constantin | Constanța | viewer | 2023 |
| Vlad Petrescu | Sibiu | editor | 2022 |
| Cristina Pop | Oradea | viewer | 2024 |
| Bogdan Rusu | Iași | viewer | 2021 |
Last click, typed as UserDto: — press a row
DataTable<ProductDto>
| Category | ||||
|---|---|---|---|---|
| Laptop Pro 14 | laptop | ★ 4.7 | 4 pcs. | 7,499 RON |
| Laptop Air 13 | laptop | ★ 4.4 | sold out | 4,999 RON |
| Phone X | phone | ★ 4.6 | 12 pcs. | 3,999 RON |
| Phone Mini | phone | ★ 4.1 | 7 pcs. | 2,499 RON |
| Studio Headphones | audio | ★ 4.8 | 20 pcs. | 1,299 RON |
| Portable Speaker | audio | ★ 4 | sold out | 449 RON |
| Mouse Ergo | accessory | ★ 4.5 | 35 pcs. | 349 RON |
| Mechanical Keyboard | accessory | ★ 4.9 | 9 pcs. | 699 RON |
| Hub USB-C | accessory | ★ 3.9 | 50 pcs. | 229 RON |
| Monitor 27" | accessory | ★ 4.6 | 3 pcs. | 1,899 RON |
How you use it
<app-data-table
[rows]="users()"
[columns]="userColumns"
[trackBy]="byId"
(rowClick)="open($event)" <!-- $event: UserDto -->
>
<ng-template appCell="role" [appCellOf]="users()" let-user>
<app-role-badge [role]="user.role" /> <!-- user: UserDto, autocomplete works -->
</ng-template>
</app-data-table>One more example: Select<T>
// Another classic example: a Select<T> that works with any object
@Component({
selector: 'app-select',
template: `
<select (change)="pick($any($event.target).selectedIndex - 1)">
<option disabled [selected]="!value()">{{ placeholder() }}</option>
@for (o of options(); track $index) {
<option [selected]="isSelected(o)">{{ displayWith()(o) }}</option>
}
</select>
`,
})
export class Select<T> {
readonly options = input.required<readonly T[]>();
readonly value = model<T | null>(null);
readonly displayWith = input<(item: T) => string>((item) => String(item));
readonly compareWith = input<(a: T, b: T) => boolean>((a, b) => a === b);
readonly placeholder = input('Choose…');
protected isSelected(o: T) {
const v = this.value();
return v !== null && this.compareWith()(o, v);
}
protected pick(i: number) {
this.value.set(this.options()[i] ?? null);
}
}
// <app-select [options]="countries()" [(value)]="country" [displayWith]="byName" />Composition: a domain wrapper over the generic component
The generic component must not know anything about the domain. When you have the same users table in three pages, you don’t add showRoleColumn to DataTable — you make a UsersTable in the users feature that wraps it.
// Composition: the domain component wraps the generic component.
// DataTable<T> lives in shared/ui; UsersTable lives in features/users/ui.
@Component({
selector: 'app-users-table',
imports: [DataTable, CellTemplate, RoleBadge],
template: `
<app-data-table [rows]="users()" [columns]="columns" [trackBy]="byId" clickable (rowClick)="open.emit($event)">
<ng-template appCell="role" [appCellOf]="users()" let-user>
<app-role-badge [role]="user.role" />
</ng-template>
</app-data-table>
`,
})
export class UsersTable {
readonly users = input.required<User[]>();
readonly open = output<User>();
protected readonly byId = (u: User) => u.id;
protected readonly columns: Column<User>[] = [/* the domain columns, in one place */];
}Anti-pattern: the “god component”
// ✗ The “generic component” that grew out of control
@Component({ selector: 'app-table' })
export class Table {
@Input() data: any[] = []; // any → zero safety
@Input() showRoleColumn = false; // domain knowledge in shared
@Input() roleColors: Record<string, string> = {};
@Input() isProductTable = false; // ifs on “what kind of table is this”
@Input() priceCurrency = 'RON';
@Input() highlightAdmins = true;
@Input() exportToExcel = false;
// ... 30 more inputs
}
// The solution: generic types + cell templates + domain wrappers.When do I make it generic?
- The same behaviour on different data: table, list, select, autocomplete, tree, paginator.
- You already have 2–3 real uses (not “we might need it”).
- You can describe the API without domain words (no “user”, “order”, “invoice”).
- You want to put it in
shared/uior a library.
- You have a single use — premature generalisation costs more than duplication.
- The differences are in behaviour, not in data (then you have two different components).
- The API would need flags like
isProductTable. - It is a domain component (
InvoiceSummary) — it stays in the feature.
Configuration vs templates
- Configuration (
columnswithformat): for the simple cases, 80% of the columns. - Templates (
appCell): for rich cells (badges, buttons, links). - Projection (
<ng-content>): for fixed areas (toolbar, footer).
For parts of the UI chosen by whoever uses the component, see ng-template & ngTemplateOutlet; for the types behind T, Interfaces & models.
Common pitfalls
- T inferred as
unknown: if the input it is inferred from is missing or isany. Type the data at the source. - The
let-*template variables areanywithoutngTemplateContextGuard— add the directive with the guard. - Implicit
track rowis fine for stable data; for data reloaded from the server, give atrackByon the id. - Too many inputs: beyond ~8–10 is a sign you are mixing responsibilities. Split it or use templates.