Eine generische Komponente hat einen Typparameter — DataTable<T>, Select<T>, List<T> — und funktioniert mit beliebigen Daten, wobei die Typsicherheit im Template und in den Outputs erhalten bleibt. Angular leitet T aus dem Input ab, den du bindest (zum Beispiel aus [rows]), genau wie bei einer generischen Funktion in TypeScript.
Beispiel: DataTable<T>
Die Tabelle kümmert sich um das Verhalten (Sortierung, Auswahl, leere Zeile), und wer sie verwendet, liefert die Spalten (typisierte Konfiguration) und optional Templates für besondere Zellen.
export interface Column<T> {
key: Extract<keyof T, string>; // nur echte Schlüssel von 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> { // ← der Typparameter der Komponente
readonly rows = input.required<readonly T[]>(); // ← T wird von hier abgeleitet
readonly columns = input.required<readonly Column<T>[]>();
readonly trackBy = input<(row: T) => unknown>((row) => row);
readonly rowClick = output<T>(); // ← typisierter 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(() => { /* Sortierung nach 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 |
Letzter Klick, typisiert als UserDto: — klicke auf eine Zeile
DataTable<ProductDto>
| Kategorie | ||||
|---|---|---|---|---|
| Laptop Pro 14 | laptop | ★ 4.7 | 4 Stk. | 7.499 RON |
| Laptop Air 13 | laptop | ★ 4.4 | ausverkauft | 4.999 RON |
| Smartphone X | phone | ★ 4.6 | 12 Stk. | 3.999 RON |
| Smartphone Mini | phone | ★ 4.1 | 7 Stk. | 2.499 RON |
| Studio-Kopfhörer | audio | ★ 4.8 | 20 Stk. | 1.299 RON |
| Tragbarer Lautsprecher | audio | ★ 4 | ausverkauft | 449 RON |
| Mouse Ergo | accessory | ★ 4.5 | 35 Stk. | 349 RON |
| Mechanische Tastatur | accessory | ★ 4.9 | 9 Stk. | 699 RON |
| Hub USB-C | accessory | ★ 3.9 | 50 Stk. | 229 RON |
| Monitor 27" | accessory | ★ 4.6 | 3 Stk. | 1.899 RON |
So verwendest du sie
<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 funktioniert -->
</ng-template>
</app-data-table>Noch ein Beispiel: Select<T>
// Ein weiteres klassisches Beispiel: ein Select<T>, das mit jedem Objekt funktioniert
@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('Auswählen…');
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" />Komposition: ein Domänen-Wrapper um die generische Komponente
Die generische Komponente darf nichts über die Domäne wissen. Wenn du dieselbe Benutzertabelle auf drei Seiten hast, fügst du kein showRoleColumn zu DataTable hinzu — du baust eine UsersTable im Users-Feature, die sie umschließt.
// Komposition: Die Domänenkomponente umschließt die generische Komponente.
// DataTable<T> liegt in shared/ui; UsersTable liegt 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>[] = [/* die Domänenspalten an einer Stelle */];
}Anti-Pattern: die „God Component“
// ✗ Die „generische Komponente“, die außer Kontrolle geraten ist
@Component({ selector: 'app-table' })
export class Table {
@Input() data: any[] = []; // any → null Sicherheit
@Input() showRoleColumn = false; // Domänenwissen in shared
@Input() roleColors: Record<string, string> = {};
@Input() isProductTable = false; // ifs auf „was für eine Tabelle bin ich“
@Input() priceCurrency = 'RON';
@Input() highlightAdmins = true;
@Input() exportToExcel = false;
// ... 30 weitere Inputs
}
// Die Lösung: generische Typen + Zell-Templates + Domänen-Wrapper.Wann mache ich sie generisch?
- Dasselbe Verhalten auf unterschiedlichen Daten: Tabelle, Liste, Select, Autocomplete, Tree, Paginator.
- Du hast bereits 2–3 echte Verwendungen (nicht „vielleicht brauchen wir es“).
- Du kannst die API ohne Domänenwörter beschreiben (kein „User“, „Order“, „Invoice“).
- Du willst sie in
shared/uioder in eine Bibliothek legen.
- Du hast nur eine einzige Verwendung — voreilige Verallgemeinerung kostet mehr als Duplizierung.
- Die Unterschiede liegen im Verhalten, nicht in den Daten (dann hast du zwei verschiedene Komponenten).
- Die API bräuchte Flags wie
isProductTable. - Es ist eine Domänenkomponente (
InvoiceSummary) — sie bleibt im Feature.
Konfiguration vs. Templates
- Konfiguration (
columnsmitformat): für die einfachen Fälle, 80 % der Spalten. - Templates (
appCell): für aufwendige Zellen (Badges, Buttons, Links). - Projektion (
<ng-content>): für feste Bereiche (Toolbar, Footer).
Für UI-Teile, die der Verwender der Komponente bestimmt, siehe ng-template & ngTemplateOutlet; für die Typen hinter T Interfaces & Modelle.
Häufige Stolperfallen
- T wird als
unknownabgeleitet: wenn der Input, aus dem abgeleitet wird, fehlt oderanyist. Typisiere die Daten an der Quelle. - Die
let-*-Template-Variablen sindanyohnengTemplateContextGuard— füge die Direktive mit dem Guard hinzu. - Das implizite
track rowist für stabile Daten in Ordnung; bei Daten, die vom Server neu geladen werden, gib eintrackByauf die ID an. - Zu viele Inputs: Mehr als etwa 8–10 sind ein Zeichen dafür, dass du Verantwortlichkeiten vermischst. Teile die Komponente auf oder nutze Templates.