📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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 : strictTemplates est un réglage angularCompilerOptions, distinct du strict: true de TypeScript. Il n'est pas activé automatiquement par ce dernier, il faut le déclarer explicitement (c'est ce que fait le schematic ng 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 :

  1. TemplateRef<unknown> ne dit rien sur le contexte.
  2. Le compilateur Angular ne type-check un <ng-template> qu'à partir des directives qui matchent cet élément précis. Or List n'est jamais appliqué sur le <ng-template> du consumer : il le reçoit seulement via projection de contenu, à distance. Un guard posé sur List est 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électeur ng-template[listRow] : elle ne s'applique que sur un <ng-template> qui porte l'attribut listRow, c'est-à-dire exactement là où le consumer écrit son template.
  • Son listRow = input.required<T[]>() sert à inférer T à cet endroit précis, à partir de la valeur bindée. C'est le même mécanisme que celui qu'utilise NgForOf en interne.
  • ngTemplateContextGuard est déclaré sur ListRow, pas sur List. List<T> ne fait plus que récupérer la directive via contentChild.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: true dans angularCompilerOptions. Sans ça, le guard existe mais n'est jamais consulté et tout retombe en any.


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é par let-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-user capture $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

  1. Rends tes composants génériques. export class MyComponent<T> + input.required<T[]>(). Les signal inputs préservent la généricité.
  2. 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 via contentChild.
  3. Extrais une directive dédiée par template projeté, avec le sélecteur ng-template[monAttribut], un signal input générique et son propre ngTemplateContextGuard.
  4. Vérifie que strictTemplates: true est activé dans angularCompilerOptions. Ce n'est pas automatique avec strict: true côté TypeScript.
  5. 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.
  6. Aligne l'interface TS et le contexte runtime. Chaque clé passée à ngTemplateOutletContext doit exister dans l'interface. Un test qui valide la forme du context aide à ne pas régresser.
  7. 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.