Un composant générique a un paramètre de type — DataTable<T>, Select<T>, List<T> — et fonctionne avec n’importe quel type de données, tout en gardant la sûreté de typage dans le template et dans les outputs. Angular infère T à partir de l’input que vous liez (par exemple depuis [rows]), exactement comme une fonction générique en TypeScript.
Exemple : DataTable<T>
Le tableau gère le comportement (tri, sélection, ligne vide), et celui qui l’utilise fournit les colonnes (configuration typée) et, éventuellement, des templates pour les cellules spéciales.
export interface Column<T> {
key: Extract<keyof T, string>; // uniquement de vraies clés de 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> { // ← le paramètre de type du composant
readonly rows = input.required<readonly T[]>(); // ← T est inféré à partir d’ici
readonly columns = input.required<readonly Column<T>[]>();
readonly trackBy = input<(row: T) => unknown>((row) => row);
readonly rowClick = output<T>(); // ← output typé
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(() => { /* tri selon 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 |
Dernier clic, typé comme UserDto: — cliquez sur une ligne
DataTable<ProductDto>
| Catégorie | ||||
|---|---|---|---|---|
| Laptop Pro 14 | laptop | ★ 4.7 | 4 pcs | 7 499 RON |
| Laptop Air 13 | laptop | ★ 4.4 | épuisé | 4 999 RON |
| Smartphone X | phone | ★ 4.6 | 12 pcs | 3 999 RON |
| Smartphone Mini | phone | ★ 4.1 | 7 pcs | 2 499 RON |
| Casque de studio | audio | ★ 4.8 | 20 pcs | 1 299 RON |
| Enceinte portable | audio | ★ 4 | épuisé | 449 RON |
| Mouse Ergo | accessory | ★ 4.5 | 35 pcs | 349 RON |
| Clavier mécanique | 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 |
Comment l’utiliser
<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, l’autocomplétion fonctionne -->
</ng-template>
</app-data-table>Un autre exemple : Select<T>
// Un autre exemple classique : un Select<T> qui fonctionne avec n’importe quel objet
@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('Choisir…');
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 : un wrapper métier autour du composant générique
Le composant générique ne doit rien savoir du domaine métier. Quand vous avez le même tableau d’utilisateurs dans trois pages, vous n’ajoutez pas showRoleColumn à DataTable — vous créez un UsersTable dans la feature users qui l’enveloppe.
// Composition : le composant métier enveloppe le composant générique.
// DataTable<T> vit dans shared/ui ; UsersTable vit dans 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>[] = [/* les colonnes métier, au même endroit */];
}Anti-pattern : le « god component »
// ✗ Le « composant générique » qui a dérapé
@Component({ selector: 'app-table' })
export class Table {
@Input() data: any[] = []; // any → aucune sûreté
@Input() showRoleColumn = false; // connaissance du domaine dans shared
@Input() roleColors: Record<string, string> = {};
@Input() isProductTable = false; // des if sur « quel type de tableau est-ce »
@Input() priceCurrency = 'RON';
@Input() highlightAdmins = true;
@Input() exportToExcel = false;
// ... 30 inputs de plus
}
// La solution : types génériques + templates de cellule + wrappers métier.Quand le rendre générique ?
- Le même comportement sur des données différentes : tableau, liste, select, autocomplete, arbre, paginateur.
- Vous avez déjà 2 à 3 usages réels (pas « on pourrait en avoir besoin »).
- Vous pouvez décrire l’API sans mots du domaine (ni « utilisateur », ni « commande », ni « facture »).
- Vous voulez le mettre dans
shared/uiou dans une bibliothèque.
- Vous n’avez qu’un seul usage — une généralisation prématurée coûte plus cher que la duplication.
- Les différences portent sur le comportement, pas sur les données (alors ce sont deux composants distincts).
- L’API aurait besoin de flags comme
isProductTable. - C’est un composant métier (
InvoiceSummary) — il reste dans la feature.
Configuration ou templates
- Configuration (
columnsavecformat) : pour les cas simples, 80 % des colonnes. - Templates (
appCell) : pour les cellules riches (badges, boutons, liens). - Projection (
<ng-content>) : pour les zones fixes (barre d’outils, pied de tableau).
Pour les parties d’interface choisies par celui qui utilise le composant, voir ng-template et ngTemplateOutlet ; pour les types derrière T, Interfaces et modèles.
Pièges fréquents
- T inféré comme
unknown: si l’input dont il est déduit est absent ou vautany. Typez les données à la source. - Les variables de template
let-*valentanysansngTemplateContextGuard— ajoutez la directive avec le guard. track rowimplicite convient aux données stables ; pour des données rechargées depuis le serveur, fournissez untrackBysur l’id.- Trop d’inputs : au-delà de 8 à 10 environ, c’est le signe que vous mélangez les responsabilités. Découpez ou utilisez des templates.