~8 min de lecture
NgTemplateOutlet en Angular 20 : 4 patterns pour construire des composants génériques 100% typés
Tu ouvres le composant DataTable que tu as écrit la semaine dernière. Il accepte un tableau items() et un template pour rendre chaque row. Ça marche, ça bind, ça affiche. Sauf que dans le template consumer, ton row est typé any. Pas d'autocomplete, pas d'erreur si tu écris row.usernameeee, pas de check si le champ n'existe pas. Le premier junior qui passe casse le composant sans que le compilateur bronche.
C'est le pain quotidien de NgTemplateOutlet : la mécanique de projection de template est puissante, mais Angular n'a aucun moyen de deviner ce que tu mets dans le contexte. Résultat, tu balances du any dans tes composants les plus critiques et tu perds tout le bénéfice de TypeScript à l'endroit exact où tu construis ton design system.
Bonne nouvelle : depuis Angular 17 (et amélioré en 20), tu as tout ce qu'il faut pour faire des composants génériques strictement typés. Le combo ngTemplateContextGuard + directive dédiée + signal inputs. Quatre patterns, une fois posés, tu ne reviens plus en arrière.
Valide Angular 17+. Exemples vérifiés en Angular 20 par compilation réelle (
ngc,strictTemplates: true). Attention :strictTemplatesest un réglageangularCompilerOptions, distinct dustrict: truede TypeScript. Il n'est pas activé automatiquement par ce dernier, il faut le déclarer explicitement (c'est ce que fait le schematicng new --strict, qui pose les deux côte à côte, pas l'un à cause de l'autre).
TL;DR
| Pattern | Ce que tu récupères |
|---|---|
1. ngTemplateContextGuard sur une directive dédiée |
Autocomplete + type-check dans le template consumer |
2. Composant générique <T> + signal input |
Le type de T déduit à partir de items() et propagé à la directive |
| 3. Multi-templates via directives dédiées | Row, empty state, header dans le même composant, chacun avec son contexte |
4. $implicit vs named context |
Choisir entre let-row et let-index="index" sans casser le typage |
Règle d'or : si ton composant projette un template, le typage de ce template n'est jamais porté par le composant hôte. Il doit être porté par une directive appliquée directement sur le <ng-template> du consumer, avec son propre ngTemplateContextGuard.
Le problème : le any invisible
Tu construis un List<T> réutilisable :
import { ChangeDetectionStrategy, Component, input, TemplateRef, contentChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-list',
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (item of items(); track $index) {
<li>
<ng-container
[ngTemplateOutlet]="rowTpl()"
[ngTemplateOutletContext]="{ $implicit: item, index: $index }"
/>
</li>
}
</ul>
`,
})
export class List {
items = input.required<unknown[]>();
rowTpl = contentChild.required<TemplateRef<unknown>>('row');
}
Côté consumer :
@Component({
selector: 'app-users',
imports: [List],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-list [items]="users()">
<ng-template #row let-user let-i="index">
<span>{{ i }} - {{ user.usernameeee }}</span>
</ng-template>
</app-list>
`,
})
export class Users {
users = signal<User[]>([{ id: '1', username: 'alice' }]);
}
user.usernameeee compile. i est typé any. Le premier refactor sur User.username en User.login ne remonte aucune erreur. Ton composant réutilisable est un cheval de Troie de bugs silencieux.
Le problème vient de deux endroits :
TemplateRef<unknown>ne dit rien sur le contexte.- Le compilateur Angular ne type-check un
<ng-template>qu'à partir des directives qui matchent cet élément précis. OrListn'est jamais appliqué sur le<ng-template>du consumer : il le reçoit seulement via projection de contenu, à distance. Un guard posé surListest donc invisible pour ce<ng-template>, quoi que tu fasses.
Ce deuxième point piège presque tout le monde, y compris des articles qui circulent sur le sujet : poser ngTemplateContextGuard en statique sur le composant hôte ne fait rien. Il faut le poser sur une directive appliquée directement sur le <ng-template>.
Pattern 1 : ngTemplateContextGuard, l'assertion qui débloque tout (posée au bon endroit)
ngTemplateContextGuard est une méthode statique que le compilateur Angular reconnaît spécialement, exactement comme il le fait pour NgForOf avec [ngForOf]. La solution : extraire une petite directive dédiée dont le sélecteur matche le <ng-template>, et lui donner elle-même le type T via un signal input lié à la même donnée que items().
import { ChangeDetectionStrategy, Component, contentChild, Directive, input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
interface RowContext<T> {
$implicit: T;
index: number;
}
@Directive({
selector: 'ng-template[listRow]',
})
export class ListRow<T> {
listRow = input.required<T[]>();
static ngTemplateContextGuard<T>(
_dir: ListRow<T>,
ctx: unknown,
): ctx is RowContext<T> {
return true;
}
}
@Component({
selector: 'app-list',
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (item of items(); track $index) {
<li>
<ng-container
[ngTemplateOutlet]="rowTpl()"
[ngTemplateOutletContext]="{ $implicit: item, index: $index }"
/>
</li>
}
</ul>
`,
})
export class List<T> {
items = input.required<T[]>();
rowTpl = contentChild.required(ListRow, { read: TemplateRef });
}
Trois éléments clés :
ListRow<T>est une directive au sélecteurng-template[listRow]: elle ne s'applique que sur un<ng-template>qui porte l'attributlistRow, c'est-à-dire exactement là où le consumer écrit son template.- Son
listRow = input.required<T[]>()sert à inférerTà cet endroit précis, à partir de la valeur bindée. C'est le même mécanisme que celui qu'utiliseNgForOfen interne. ngTemplateContextGuardest déclaré surListRow, pas surList.List<T>ne fait plus que récupérer la directive viacontentChild.required(ListRow, { read: TemplateRef }).
Côté consumer :
<app-list [items]="users()">
<ng-template [listRow]="users()" let-user let-i="index">
<!-- user est typé User, i est typé number -->
<span>{{ i }} - {{ user.username }}</span>
</ng-template>
</app-list>
Le premier user.usernameeee te sort une erreur de compilation. Le refactor username en login te remonte tous les templates consumers.
La contrepartie : tu répètes la donnée, une fois sur [items] (pour que List sache quoi boucler), une fois sur [listRow] (pour que le compilateur sache quoi inférer). C'est le prix réel de l'inférence de type sur un template projeté : sans ce deuxième binding, rien n'indique au compilateur quel T utiliser à cet endroit précis, même si List connaît déjà son propre T.
Piège classique : oublier
strictTemplates: truedansangularCompilerOptions. Sans ça, le guard existe mais n'est jamais consulté et tout retombe enany.
Pattern 2 : composant générique <T> + signal input
En Angular 17+, les signal inputs préservent la généricité. Contrairement au vieux @Input(), le type ne se fait pas raboter à unknown au passage :
export class List<T> {
items = input.required<T[]>();
}
Ça règle la moitié du problème : T circule bien du call-site ([items]="users()") jusqu'à l'intérieur de List. Mais ça ne suffit pas à typer le <ng-template> projeté : ce composant n'a aucune prise sur cet élément-là (voir Pattern 1). C'est pour ça que la directive ListRow<T> a besoin de son propre signal input : chaque endroit où T doit être inféré a besoin de son propre point d'ancrage typé, composant hôte et directive de template compris.
Si tu as besoin de contraindre T (par exemple, tu veux exiger un champ id), fais-le aux deux endroits pour rester cohérent :
interface Identifiable {
id: string | number;
}
export class List<T extends Identifiable> {
items = input.required<T[]>();
// ...
}
export class ListRow<T extends Identifiable> {
listRow = input.required<T[]>();
// ...
}
Le template consumer refusera un items (ou un listRow) dont les éléments n'ont pas de id. Un compile-time check pour ta contrainte métier, gratuit, et cohérent des deux côtés.
Pattern 3 : multi-templates, une directive par contexte
Un DataTable en vrai vie a rarement un seul template. Il y a une row, un état vide, souvent un header custom. Chacun avec son propre contexte, donc sa propre directive :
import { ChangeDetectionStrategy, Component, contentChild, Directive, input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
interface RowContext<T> {
$implicit: T;
index: number;
}
interface HeaderContext {
count: number;
}
@Directive({ selector: 'ng-template[dataTableRow]' })
export class DataTableRow<T> {
dataTableRow = input.required<T[]>();
static ngTemplateContextGuard<T>(
_dir: DataTableRow<T>,
ctx: unknown,
): ctx is RowContext<T> {
return true;
}
}
@Directive({ selector: 'ng-template[dataTableHeader]' })
export class DataTableHeader {
static ngTemplateContextGuard(
_dir: DataTableHeader,
ctx: unknown,
): ctx is HeaderContext {
return true;
}
}
@Component({
selector: 'app-data-table',
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (headerTpl(); as header) {
<ng-container
[ngTemplateOutlet]="header"
[ngTemplateOutletContext]="{ count: items().length }"
/>
}
@if (items().length > 0) {
<ul>
@for (item of items(); track $index) {
<li>
<ng-container
[ngTemplateOutlet]="rowTpl()"
[ngTemplateOutletContext]="{ $implicit: item, index: $index }"
/>
</li>
}
</ul>
} @else {
<ng-container [ngTemplateOutlet]="emptyTpl() ?? null" />
}
`,
})
export class DataTable<T> {
items = input.required<T[]>();
rowTpl = contentChild.required(DataTableRow, { read: TemplateRef });
headerTpl = contentChild(DataTableHeader, { read: TemplateRef });
emptyTpl = contentChild<TemplateRef<unknown>>('empty');
}
DataTableHeader n'a pas besoin de généricité : son contexte (HeaderContext) ne dépend pas de T, donc pas de signal input à porter, juste le guard. emptyTpl n'a aucun contexte à typer : une simple query par variable de référence (#empty) suffit, pas besoin de directive.
Une directive par contexte, jamais un guard unique posé sur le composant hôte qui tenterait de couvrir tous les templates à la fois : le compilateur type-check chaque <ng-template> en fonction de la directive qui le matche, pas en fonction d'une union globale.
Consumer :
<app-data-table [items]="users()">
<ng-template dataTableHeader let-count="count">
<h2>{{ count }} utilisateurs</h2>
</ng-template>
<ng-template [dataTableRow]="users()" let-user let-i="index">
<span>{{ i }} - {{ user.username }}</span>
</ng-template>
<ng-template #empty>
<p>Aucun utilisateur</p>
</ng-template>
</app-data-table>
Chaque template a son contexte typé, chaque variable de let-* est checkée.
Pattern 4 : $implicit vs named context, et pourquoi ça change tout
Le contexte projeté a deux modes :
$implicit: consommé parlet-nom(sans=).- Named : consommé par
let-alias="key".
[ngTemplateOutletContext]="{ $implicit: item, index: $index, isFirst: $first }"
Consumer :
<ng-template [listRow]="items()" let-user let-i="index" let-first="isFirst">
<span [class.highlight]="first">{{ i }} - {{ user.username }}</span>
</ng-template>
let-usercapture$implicit(l'item).let-i="index"capture la cléindex.let-first="isFirst"capture la cléisFirst.
Le typage est fait clé par clé via l'interface du context. Si tu ajoutes une clé dans le contexte sans la déclarer dans l'interface, le compilateur ne le voit pas et l'alias tombe en any. La discipline : le context runtime et l'interface TS doivent rester alignés. Un test qui valide la forme du context aide à éviter le drift.
Une erreur fréquente : mettre plusieurs $implicit implicites ($implicit: item et $implicit: index). Le dernier écrase le premier silencieusement. Réserve $implicit à la valeur principale (l'item) et passe tout le reste en named.
Before / After : un Select<T> typé
Avant :
@Component({
selector: 'app-select',
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (option of options(); track $index) {
<ng-container
[ngTemplateOutlet]="optionTpl()"
[ngTemplateOutletContext]="{ $implicit: option }"
/>
}
`,
})
export class Select {
options = input.required<unknown[]>();
optionTpl = contentChild.required<TemplateRef<unknown>>('option');
}
Consumer : let-opt est any, opt.labeeeel compile, aucun refactor safe.
Après :
interface OptionContext<T> {
$implicit: T;
selected: boolean;
}
@Directive({ selector: 'ng-template[selectOption]' })
export class SelectOption<T> {
selectOption = input.required<T[]>();
static ngTemplateContextGuard<T>(
_dir: SelectOption<T>,
ctx: unknown,
): ctx is OptionContext<T> {
return true;
}
}
@Component({
selector: 'app-select',
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (option of options(); track $index) {
<ng-container
[ngTemplateOutlet]="optionTpl()"
[ngTemplateOutletContext]="{ $implicit: option, selected: option === value() }"
/>
}
`,
})
export class Select<T> {
options = input.required<T[]>();
value = input<T | null>(null);
optionTpl = contentChild.required(SelectOption, { read: TemplateRef });
}
Consumer :
<app-select [options]="cities()" [value]="selected()">
<ng-template [selectOption]="cities()" let-city let-isSelected="selected">
<span [class.selected]="isSelected">{{ city.name }}</span>
</ng-template>
</app-select>
city est typé City, isSelected est typé boolean, tout refactor sur City remonte au consumer. Zéro any, zéro cast. Le seul coût : [selectOption]="cities()" répète la donnée déjà passée à [options], le prix de l'inférence à l'endroit exact où le compilateur en a besoin.
Récap actionnable
- Rends tes composants génériques.
export class MyComponent<T>+input.required<T[]>(). Les signal inputs préservent la généricité. - N'attends rien d'un guard posé sur le composant hôte. Le compilateur type-check un
<ng-template>d'après les directives qui matchent cet élément, pas d'après le composant qui le consomme viacontentChild. - Extrais une directive dédiée par template projeté, avec le sélecteur
ng-template[monAttribut], un signal input générique et son proprengTemplateContextGuard. - Vérifie que
strictTemplates: trueest activé dansangularCompilerOptions. Ce n'est pas automatique avecstrict: truecôté TypeScript. - Une directive par contexte, jamais un guard union sur le composant hôte. Si tu as plusieurs templates avec des contextes différents (row, header, empty...), une directive par contexte, pas un guard fourre-tout.
- Aligne l'interface TS et le contexte runtime. Chaque clé passée à
ngTemplateOutletContextdoit exister dans l'interface. Un test qui valide la forme du context aide à ne pas régresser. - Réserve
$implicità la valeur principale. Tout le reste passe en named context.
Le combo ngTemplateContextGuard + directive dédiée + signal inputs, c'est ce qui fait la différence entre un design system qui vieillit bien et un design system que personne n'ose refactorer. Ça coûte une directive de plus par template projeté et un binding de donnée répété, mais tu récupères des heures à chaque refactor de modèle.
Si tu veux approfondir Angular moderne avec ce genre de patterns, jette un œil à EasyAngularKit.