~9 min de lecture
Tes composants dynamiques échouent en silence : reflectComponentType est le garde-fou que tu n'utilises pas
Tu construis un dashboard à widgets, un système de plugins, ou un service de modales générique. Tu charges des composants avec createComponent() ou NgComponentOutlet (si tu hésites encore entre les deux, l'arbre de décision est là-bas), tu pousses les données avec setInput(), et tout fonctionne. Jusqu'au jour où un input est renommé ou aliasé, et où ton widget se met à afficher sa valeur par défaut en production. Sans erreur. Sans warning. Sans rien.
Le coupable n'est pas setInput(). C'est toi qui appelles une API dynamique à l'aveugle alors qu'Angular fournit une API publique pour lire le contrat réel d'un composant au runtime : reflectComponentType(). Quasiment personne ne l'utilise. Voyons pourquoi tu devrais.
Tout ce qui suit est vérifié à l'exécution sur Angular v22. Côté versions :
reflectComponentType()existe depuis la 14.1, le champtransformdu mirror depuis la 16.2, le champisSignaldepuis la 18.1, et le libellé exact du message NG0303 cité plus bas date de la 20.2 (le texte diffère sur les versions antérieures). Les exemples utilisentinput.required()etoutput(), donc Angular 17.3 minimum ; le cas 3 s'appuie en plus suroutputBinding()et l'optionbindingsdecreateComponent(), disponibles depuis la 20.0.
Le problème : par défaut, setInput() ne throw pas sur un nom d'input inconnu
Prenons un widget de dashboard classique, chargé dynamiquement :
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'app-widget-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-content select="[card-header]" />
<ng-content />
`,
})
export class WidgetCard {
readonly title = input.required<string>();
readonly count = input(0, { alias: 'itemCount' });
readonly closed = output<void>({ alias: 'cardClosed' });
}
Et le code qui le monte dynamiquement :
const ref = createComponent(WidgetCard, { environmentInjector });
ref.setInput('titel', 'Sales'); // faute de frappe
ref.setInput('count', 42); // nom de propriété de classe, pas le nom public
Ces deux lignes sont fausses. Aucune ne throw. En mode dev, chacune produit son propre console.error ; voici celui de la faute de frappe, émis sur une seule ligne dans ta console :
NG0303: Can't set value of the 'titel' input on the 'WidgetCard' component. Make sure that the 'titel' property is declared as an input using the input() or model() function or the @Input() decorator.
Un console.error, pas une exception. Ton code continue et la valeur n'est jamais appliquée. Comme title est un input.required jamais renseigné, sa première lecture lèvera un NG0950 ("Input is required but no value is available yet."), loin de la ligne fautive. En production, ngDevMode, le flag interne qu'Angular passe à false dans un build de prod et qui conditionne tous ses messages de diagnostic, est désactivé : plus aucun log NG0303. Le NG0950, lui, survit au build de prod, mais dépouillé de son message : un crash muet, encore plus loin de la cause. Et pour un input optionnel, même pas de crash, comme on va le voir.
La deuxième ligne est encore plus vicieuse : count est bien le nom de la propriété dans la classe. Mais setInput() attend le nom public de l'input, celui utilisé dans les templates. Avec alias: 'itemCount', le nom public est itemCount, et setInput('count', 42) échoue exactement comme une faute de frappe. Un refactoring qui ajoute un alias casse silencieusement tous les appels dynamiques existants : le widget affiche count = 0 et personne ne sait pourquoi.
Pour être complet, deux cas particuliers où setInput() throw pour de vrai :
- NG0317 si le composant a été créé avec
bindings: [inputBinding(...)]outwoWayBinding(...): tout appel àsetInput()sur cette instance est refusé, même avec un nom d'input parfaitement valide. Les deux approches ne se mélangent pas sur une même instance. - NG0303 devient une exception (au lieu d'un
console.error) dans un TestBed configuré avecerrorOnUnknownProperties: true. Active-le : c'est le contexte où ce bug fait échouer un test au lieu de polluer une console.
Hors de ces deux cas, le compilateur ne te protège pas : setInput(name: string, value: unknown) accepte n'importe quelle string, et le typage des templates ne s'applique pas aux composants dynamiques. Tu es seul.
La solution : lire le contrat au runtime avec reflectComponentType()
reflectComponentType() prend une classe de composant et retourne un ComponentMirror, un objet qui décrit son API publique :
import { reflectComponentType } from '@angular/core';
const mirror = reflectComponentType(WidgetCard);
mirror.selector; // 'app-widget-card'
mirror.type; // WidgetCard (la classe elle-même)
mirror.isStandalone; // true
mirror.inputs; // [{ propName: 'title', templateName: 'title', isSignal: true },
// { propName: 'count', templateName: 'itemCount', isSignal: true }]
mirror.outputs; // [{ propName: 'closed', templateName: 'cardClosed' }]
mirror.ngContentSelectors; // ['[card-header]', '*']
Si la classe passée n'est pas un composant (un service, une directive, une classe quelconque), la fonction retourne null au lieu de throw. Pense à gérer ce cas. Le mirror est typé en lecture seule (ReadonlyArray), mais ce n'est qu'une garantie TypeScript : ses tableaux (inputs, outputs, ngContentSelectors) ne sont pas gelés au runtime, donc ne les mute pas.
Trois détails vérifiés au runtime, parce que la nomenclature est piégeuse :
propNameest le nom de la propriété dans la classe,templateNameest le nom public (l'alias s'il existe, sinon le même nom). C'esttemplateNamequesetInput()attend. Attention si tu lis l'exemple de la doc officielle : le JSDoc dereflectComponentTypeinverse les deux dans sonexpect(), ce qui contredit le comportement réel. Fie-toi au runtime, pas à l'exemple.isSignalte dit si l'input est déclaré avecinput()/model()ou avec le décorateur@Input. Pratique en migration pour mesurer l'avancement.transformn'est exposé que pour les inputs déclarés avec le décorateur@Input. Pour un@Input({ transform: numberAttribute }), le mirror expose la fonction. Pour uninput(0, { transform: ... })signal,transformestundefined: la transformation est encapsulée dans le signal lui-même. Ne construis pas de logique qui suppose que le mirror voit tous les transforms.
Cas 1 : transformer un bug de prod en exception au montage
Ton dashboard reçoit une config JSON : quel composant afficher, avec quelles valeurs. Entre la config (des strings) et le composant (un contrat), il n'y a aucune vérification. Ajoutons-la :
import { ComponentRef, reflectComponentType } from '@angular/core';
export function setInputsStrict<T>(
ref: ComponentRef<T>,
inputs: Record<string, unknown>,
): void {
const mirror = reflectComponentType(ref.componentType);
if (!mirror) {
throw new Error(`${ref.componentType.name} is not a component`);
}
const publicNames = new Set(mirror.inputs.map((i) => i.templateName));
for (const [name, value] of Object.entries(inputs)) {
if (!publicNames.has(name)) {
throw new Error(
`Unknown input '${name}' on ${ref.componentType.name}. ` +
`Available inputs: ${[...publicNames].join(', ')}`,
);
}
ref.setInput(name, value);
}
}
Avant : setInput('titel', ...) passe en CI, passe en review, et échoue en silence chez le client. Après : une exception explicite au montage, avec la liste des inputs valides dans le message. Le bug qui coûtait une session de debug coûte maintenant dix secondes de lecture de stack trace.
Tu peux déplacer la vérification encore plus tôt, avant même d'instancier quoi que ce soit. Au chargement de la config, tu résous chaque type de widget, tu compares les clés du JSON aux templateName du mirror, et tu rejettes la config entière avec la liste des écarts. Une config obsolète est alors détectée au démarrage de l'app, ou mieux, dans un test qui itère sur ton registre de widgets en CI. C'est le même garde-fou, déplacé du montage vers le déploiement : un alias ajouté sur un input ne peut plus atteindre la production sans casser un build.
Cas 2 : figer ton API publique dans un test qui n'instancie aucun composant
Renommer un input, ajouter un alias, changer un selector : ce sont des breaking changes pour tous les consommateurs du composant. Et pourtant, la plupart des suites de tests ne les détectent pas, parce que les tests accèdent aux propriétés de la classe (fixture.componentInstance.count), pas aux noms publics.
reflectComponentType() permet de figer le contrat dans un test qui n'instancie aucun composant, ne compile aucun template au runtime, et tourne en quelques millisecondes :
import { reflectComponentType } from '@angular/core';
import { describe, expect, it } from 'vitest';
import { WidgetCard } from './widget-card';
describe('WidgetCard public API contract', () => {
const mirror = reflectComponentType(WidgetCard)!;
it('keeps its selector stable', () => {
expect(mirror.selector).toBe('app-widget-card');
});
it('exposes the expected public inputs', () => {
const publicInputs = mirror.inputs.map((i) => i.templateName).sort();
expect(publicInputs).toEqual(['itemCount', 'title']);
});
it('exposes the expected public outputs', () => {
const publicOutputs = mirror.outputs.map((o) => o.templateName);
expect(publicOutputs).toEqual(['cardClosed']);
});
});
Ce test échoue si quelqu'un renomme itemCount en count, supprime l'alias, ou change le selector. Exactement les régressions qui cassent les templates qui l'utilisent et les configs de widgets, et que tes tests unitaires classiques laissent passer. C'est un test de non-régression d'API, pas un test de comportement : pour le versant comportement, teste tes inputs et outputs avec inputBinding / outputBinding. Les deux sont complémentaires.
Cas 3 : brancher un output seulement quand il existe
Cas concret : une liste de notifications à l'écran, chaque type de notification a son propre composant, et certains exposent un output deleteMe quand d'autres n'en ont pas. L'hôte les monte avec createComponent() et son option bindings.
Brancher l'output à l'aveugle ne pardonne pas en dev : à l'inverse de setInput(), un outputBinding() vers un output inexistant throw un NG0316 dès la création (does not have an output with a public name of "deleteMe"). En prod, ce garde disparaît du build comme le log NG0303 : le listener n'est simplement jamais branché, en silence. mirror.outputs tranche avant d'instancier :
import { Binding, Type, outputBinding, reflectComponentType } from '@angular/core';
function hasOutput(component: Type<unknown>, name: string): boolean {
const mirror = reflectComponentType(component);
return mirror?.outputs.some((o) => o.templateName === name) ?? false;
}
const bindings: Binding[] = [];
if (hasOutput(notification.component, 'deleteMe')) {
bindings.push(outputBinding('deleteMe', () => this.dismiss(notification)));
}
const ref = this.vcr.createComponent(notification.component, { bindings });
ref.setInput('message', notification.message);
Même réflexe que pour les inputs : compare sur templateName, le nom public, jamais sur propName. Et le mélange est sans risque : des bindings limités à des outputBinding ne verrouillent pas setInput(), puisque le NG0317 vu plus haut ne se déclenche qu'avec inputBinding ou twoWayBinding.
Cas 4 : en finir avec les index magiques de projectableNodes
Dernier échec silencieux du lot, côté projection de contenu cette fois. createComponent() accepte des projectableNodes : un tableau de tableaux de nœuds DOM, où chaque position correspond à un <ng-content> du template. Mais dans quel ordre ? Celui des <ng-content> dans le template. Et tu n'as aucun moyen de le connaître sans ouvrir le fichier. Jusqu'à ce que le template change et que ton header parte dans le mauvais slot.
mirror.ngContentSelectors te donne cet ordre, au runtime :
const mirror = reflectComponentType(WidgetCard)!;
// ['[card-header]', '*'] : l'ordre exact des <ng-content> du template
const slotIndex = mirror.ngContentSelectors.indexOf('[card-header]');
const projectableNodes: Node[][] = mirror.ngContentSelectors.map(() => []);
projectableNodes[slotIndex] = [headerElement];
const ref = createComponent(WidgetCard, { environmentInjector, projectableNodes });
Avant : un index magique projectableNodes: [[headerElement], []] qui casse dès qu'on réordonne les <ng-content>. Après : une résolution par selector qui survit aux refactorings de template. Deux notes au passage : un <ng-content> sans attribut select apparaît comme '*' dans le tableau, et indexOf renvoie -1 si le selector n'existe pas. Vérifie-le avant d'assigner, sinon tu retombes dans un échec silencieux, cette fois de ton propre code.
Ce que reflectComponentType() ne fait pas
Pour éviter de te vendre un couteau suisse qui n'existe pas :
- Pas de types. Le mirror te donne des noms, pas les types TypeScript des inputs. La validation de valeur reste ton problème (un schéma Zod par widget, par exemple).
- Pas les inputs exposés par les host directives, le mécanisme
hostDirectivesqui greffe le comportement d'une directive sur un composant sans passer par son template (détaillé dans notre article sur la composition par host directives). Le mirror décrit le composant lui-même. Vérifié : un input exposé viahostDirectivesn'apparaît pas dansmirror.inputs, alors quesetInput(), lui, sait le renseigner. Si ton système de plugins repose dessus, le garde-fou strict du cas 1 produira des faux positifs, et lehasOutputdu cas 3 des faux négatifs (un listener jamais branché, en silence) : à toi d'élargir les deux vérifications. - Pas une API de méta-programmation générale. C'est une photo du contrat compilé, rien de plus : tu lis, tu ne modifies pas.
Récap actionnable
- Par défaut,
setInput()avec un nom d'input inconnu ne throw pas :console.errorNG0303 en dev, silence total en prod. Les deux seules exceptions : NG0317 si l'instance a été créée avecinputBinding/twoWayBinding(et alors toutsetInput()est refusé, même valide), et un TestBed avecerrorOnUnknownProperties: truequi transforme NG0303 en exception. setInput()attend le nom public (templateName), pas le nom de propriété. Un alias ajouté après coup casse les appels existants sans bruit.reflectComponentType(MyComponent)retourne unComponentMirroravecselector,inputs,outputs,ngContentSelectors,isStandalone,type. Etnullsi la classe n'est pas un composant.- Dans
mirror.inputs:propName= propriété de classe,templateName= nom public,isSignal= déclaré avecinput()/model(). L'exemple du JSDoc officiel inversepropNameettemplateName: fie-toi au runtime. transformn'est visible dans le mirror que pour les inputs déclarés avec le décorateur@Input, pas pour les signal inputs.- Côté versions :
reflectComponentType()depuis la 14.1,transformdepuis la 16.2,isSignaldepuis la 18.1, libellé actuel de NG0303 depuis la 20.2. - Quatre usages rentables dès aujourd'hui : valider les inputs avant
setInput()(au montage, ou dès le chargement de la config), écrire des tests de contrat d'API publique sans instancier le composant, brancher unoutputBinding()seulement quandmirror.outputsconfirme l'output (sinon NG0316 à la création en dev, listener jamais branché en prod), et résoudre l'ordre desprojectableNodespar selector au lieu d'indices magiques.
Si tu as du createComponent() ou du NgComponentOutlet quelque part dans ta codebase, tu as déjà le problème. Le garde-fou tient en une vingtaine de lignes.