~10 min de lecture

NG0201: No provider for HostAttributeToken : ce que inject() lit vraiment sur ton élément host

Tu as un composant Icon qui traîne depuis Angular 14. Il lit son nom d'icône avec le décorateur @Attribute(), dans un constructeur que tu voulais supprimer depuis longtemps :

export class Icon {
  constructor(@Attribute('name') readonly name: string) {}
}

Tu passes le repo en inject() partout. Ce constructeur est le dernier survivant, alors tu le convertis avec le token prévu pour ça :

export class Icon {
  readonly name = inject(new HostAttributeToken('name'));
}

Les tests passent. Le build passe. Et en prod, une page sur trente crashe, avec ceci pour tout diagnostic :

ERROR Error: NG0201

Pas de phrase, pas de lien, pas de nom de token : Angular retire les messages des builds de production, et la factory de cette erreur s'y réduit à function ko(e,t){throw new _(-201,!1)}, message remplacé par !1. Tu ne récupères le texte qu'en relançant en dev :

ERROR Error: NG0201: No provider for HostAttributeToken name found. Find more at https://v22.angular.dev/errors/NG0201

L'erreur "pas de provider" pour un attribut HTML que tu n'as jamais eu l'intention de fournir via un providers: []. Inutile de fouiller tes injecteurs : le problème est ailleurs. La migration a l'air d'un remplacement 1-pour-1, elle ne l'est pas. @Attribute() renvoie null quand l'attribut est absent, HostAttributeToken throw. Sur les <app-icon> sans attribut name, le décorateur laissait passer un null que ton template affichait sans broncher. Le token fait tomber la vue.

Cet article couvre ce que le token lit exactement, le contrat qui change à la migration, quatre pièges qui ne se voient pas en relecture de diff, et le gain qui justifie quand même de migrer. Pour le NG0201 dans son sens classique, celui d'un provider réellement manquant, c'est l'article dédié au provider oublié.

Valide Angular 17.3+ (introduction de HostAttributeToken). HOST_TAG_NAME, en fin d'article, demande Angular 18+. Comportements vérifiés sur Angular 22.0.7, en zoneless.


TL;DR

Écriture dans le template inject(new HostAttributeToken('size')) Attribut DOM effectif
<app-icon size="32"> '32' "32"
<app-icon size=""> '' ""
<app-icon size> '' ""
<app-icon [attr.size]="'99'"> absent (throw ou null) "99"
<app-icon attr.size="{{ 1 + 1 }}"> absent (throw ou null) "2"
<app-icon> absent (throw ou null) aucun

Trois choses à retenir :

  1. Le token lit l'attribut statique tel qu'écrit dans le template compilé, pas l'attribut du DOM. Tout ce qui est une liaison lui est invisible, même quand le DOM finit par porter l'attribut.
  2. Sans { optional: true }, un attribut absent throw NG0201. C'est l'inverse de @Attribute(), qui renvoie null en silence.
  3. ?? fallback ne rattrape pas la chaîne vide : <app-icon size> te donne '', pas ta valeur de repli.

Ce que le token lit, et à quel moment

inject(new HostAttributeToken('name')) ne fait pas un getAttribute() sur l'élément host. Il lit les attributs statiques tels que le compilateur de template les a enregistrés en compilant le markup du parent. Ils vivent dans la définition de la vue et sont résolus à la création de la directive. Deux conséquences.

La valeur est figée. Ce n'est pas un signal, il n'y a rien à appeler : une string posée une fois pour toutes à la construction. Si la valeur doit changer, c'est un input() qu'il te faut.

Les liaisons sont invisibles. C'est le point qui coûte le plus de temps en debug, parce que le DOM te contredit visuellement :

@Directive({ selector: '[appProbe]' })
export class Probe {
  readonly size = inject(new HostAttributeToken('size'), { optional: true });
}
<div appProbe [attr.size]="'99'"></div>

Tu ouvres les devtools, le <div> porte bien size="99". Et pourtant size vaut null dans ta directive : Angular a posé cet attribut au rendu, après la construction, via une liaison. Le token, lui, cherchait dans les attributs statiques du template, où size n'apparaît pas.

L'interpolation est le même piège avec une confusion en plus. attr.size="{{ 1 + 1 }}" est du sucre pour [attr.size] : invisible au token, bien posé dans le DOM. Sans le préfixe attr., size="{{ 1 + 1 }}" est du sucre pour une liaison de propriété : sur un composant sans input size, ça ne compile pas (NG8002: Can't bind to 'size'...), et là où ça compile, le DOM ne reçoit rien, sauf pour les propriétés qui, comme title, se recopient dans l'attribut correspondant. Dans les deux cas, le token ne voit rien.

Corollaire souvent oublié : un composant créé dynamiquement ne voit jamais d'attribut host, même si tu prépares l'élément à la main.

const hostElement = document.createElement('app-dyn');
hostElement.setAttribute('data-variant', 'ghost');

const ref = createComponent(Dyn, { hostElement, environmentInjector });
// ref.instance.variant === null

Il n'y a pas de template parent, donc pas d'attributs statiques compilés, et un composant qui rend son attribut host obligatoire ne peut plus être instancié par createComponent() sans throw. Si ton composant a vocation à être rendu dynamiquement (formulaire piloté par une config, renderer générique, dialogue), le token n'est pas le bon outil.


Le contrat d'erreur s'inverse

Le tableau qui compte pour une migration de @Attribute() vers HostAttributeToken tient en trois lignes :

Attribut absent @Attribute('name') inject(new HostAttributeToken('name')) avec { optional: true }
Résultat null throw NG0201 null
Annotation habituelle string (mensongère) string string | null
Détecté au build non non non

Le décorateur ne type rien : le : string du paramètre, c'est toi qui l'écris, alors que l'injection peut renvoyer null. Un mensonge de typage que tout le monde a arrêté de regarder. Le token a de vraies surcharges, mais il tranche à l'exécution. Sur un composant utilisé à cinquante endroits, ton diff ne te dit pas lesquels passent l'attribut.

La règle de migration est donc mécanique : si le code d'origine gérait le null, migre vers { optional: true }. Un ?? 'default', un if (!this.name), un name ?: string : autant de signaux que l'absence était un cas prévu. Ne passe en version non optionnelle que les attributs dont tu peux vérifier, template par template, qu'ils sont toujours écrits.

Un faux ami au passage : constructor(@Attribute('type') readonly type: string = 'button') te donne null quand l'attribut est absent, pas 'button'. Une valeur par défaut TypeScript ne se déclenche que sur undefined, or Angular injecte null. Si tu croises ce motif, c'est qu'il était déjà cassé avant ta migration. Il traînait jusque dans notre propre guide input() / @Attribute() / InjectionToken, qu'on vient de corriger pour cette raison.

Le bon côté : l'erreur de la version non optionnelle est franche et immédiate, là où le null du décorateur se propageait en classe CSS icon-null ou en requête vers /icons/null.svg. C'est le passage qui est piégeux, pas la destination.


Le gain réel : l'attribut devient composable

Si le token n'était qu'un @Attribute() avec un contrat d'erreur différent, il ne vaudrait pas l'article. Ce qu'il apporte, c'est que la lecture d'un attribut host devient une fonction, donc quelque chose que tu peux factoriser.

@Attribute() est un décorateur de paramètre : il ne vit que dans une signature de constructeur, ne se met pas dans un initialiseur de champ, ne s'appelle pas depuis une fonction, ne se réutilise pas. Détail qui pèse pour la suite : les décorateurs de paramètres n'existent pas dans les décorateurs standard ECMAScript. Passe ton tsconfig.json en experimentalDecorators: false et tu récoltes un TS1206: Decorators are not valid here. sur @Attribute(). inject() n'a besoin d'aucun décorateur.

Concrètement, tu peux écrire ta propre fonction d'injection, avec ta politique de repli, et la partager :

import { HostAttributeToken, inject } from '@angular/core';

export function injectVariant(fallback: 'solid' | 'ghost' = 'solid'): string {
  // `||` et pas `??` : l'attribut nu `<div appVariant data-variant>` vaut `''`.
  return inject(new HostAttributeToken('data-variant'), { optional: true }) || fallback;
}
@Directive({ selector: '[appVariant]' })
export class Variant {
  readonly variant = injectVariant();
}

Deux emplacements hors de portée du décorateur s'ouvrent au passage.

Une factory de provider au niveau de l'élément. Le useFactory d'un providers: [] de composant s'exécute dans l'injecteur attaché à l'élément, il voit donc les attributs statiques :

@Component({
  selector: 'app-card',
  template: `<ng-content />`,
  providers: [
    {
      provide: CardConfig,
      useFactory: () =>
        new CardConfig(inject(new HostAttributeToken('data-variant'), { optional: true })),
    },
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Card {}

Tous les descendants de <app-card data-variant="ghost"> reçoivent une CardConfig configurée depuis le markup, sans qu'aucun ne connaisse cet attribut.

Une host directive, c'est-à-dire une directive attachée à un composant via hostDirectives plutôt que posée dans le template (hostDirectives, sans copier-coller une ligne). Elle lit les attributs statiques écrits par le consommateur sur l'élément host, ce qui te permet d'extraire dans une directive réutilisable un comportement qui dépend d'un attribut :

@Component({
  selector: 'app-badge',
  template: `<ng-content />`,
  hostDirectives: [Variant],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Badge {
  readonly variant = inject(Variant).variant;
}

<app-badge data-variant="ghost"> donne 'ghost', alors que rien dans Badge ne mentionne l'attribut.


Piège 1 : ?? ne rattrape pas la chaîne vide

readonly size = inject(new HostAttributeToken('size'), { optional: true }) ?? '24';

Ce code a l'air correct. Il l'est pour <app-icon> : attribut absent, donc null, donc '24'. Il ne l'est pas pour <app-icon size> ni <app-icon size="">, qui donnent tous les deux la chaîne vide. Or '' n'est pas nullish (ni null ni undefined) : le ?? ne se déclenche pas, et size vaut ''.

Le token ne distingue pas l'attribut nu de l'attribut à valeur vide, exactement comme le DOM. Pour couvrir les trois cas, il te faut un repli qui traite '' comme une absence : sur une chaîne, || suffit, et c'est exactement ce que fait injectVariant() plus haut.

Ce ?? piège @Attribute() autant que le token : les deux appellent la même fonction interne, donc ils se comportent à l'identique sur l'attribut nu. Le seul cas où || ne suffit pas, c'est quand tu dois distinguer l'attribut absent de l'attribut vide, parce que les deux ne veulent pas dire la même chose chez toi. Là, écris le test toi-même, dans une fonction d'injection comme injectVariant() :

export function injectSize(): string {
  const raw = inject(new HostAttributeToken('size'), { optional: true });
  return raw === null ? '24' : raw;  // '' reste '', et se distingue de l'absence
}

Tu l'appelles où le décorateur ne pouvait pas aller : readonly size = injectSize();.

Piège 2 : le nom d'attribut est sensible à la casse, le DOM non

Le token compare le nom que tu lui passes avec le nom tel qu'écrit dans le template, casse comprise :

<div appProbe dataVariant="x"></div>
inject(new HostAttributeToken('dataVariant'), { optional: true });  // 'x'
inject(new HostAttributeToken('datavariant'), { optional: true });  // null

Sur ce même élément, getAttribute('datavariant') renvoie pourtant 'x' : le DOM normalise les noms d'attributs en minuscules pour les éléments HTML, le token non. Tu peux donc avoir un attribut parfaitement lisible dans les devtools, en minuscules puisque le navigateur l'a normalisé, et un token qui ne le trouve pas parce qu'il cherche la casse d'origine.

La parade : ne jamais écrire d'attribut en camelCase dans un template. data-variant, pas dataVariant. C'est la convention HTML de toute façon.

Piège 3 : aucun host: {} n'est lu, pas même le tien

Celui-ci surprend tout le monde :

@Component({
  selector: 'app-selfhost',
  template: `x`,
  host: { role: 'button' },
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SelfHost {
  readonly role = inject(new HostAttributeToken('role'), { optional: true });  // null
}

L'élément <app-selfhost> porte bien role="button" dans le DOM. Et role vaut null. La raison est celle de tout le reste de l'article : un host: {} est appliqué par une définition de directive, pas écrit dans le template du parent. Il ne fait donc pas partie des attributs statiques que l'injecteur consulte.

Et ça porte plus loin que ta propre définition : le host: {} d'une directive sœur posée sur le même élément est tout aussi invisible. <div appApplier appReader>, où appApplier déclare host: { role: 'button' }, laisse un DOM qui porte role="button", et un appReader dont le token vaut quand même null. Formulé positivement : le token ne lit que les attributs statiques que le consommateur a écrits en dur sur l'élément host, dans le template du parent.

Piège 4 : un attribut statique nourrit aussi l'input() du même nom

Si ton composant expose size = input('default') et lit en parallèle new HostAttributeToken('size'), <app-both size="32" /> alimente les deux : size() vaut '32', le token aussi. Aucun avertissement, juste deux sources de vérité qui divergeront dès qu'un consommateur écrira [size]="..." : l'input suivra, le token restera à null (ou throw s'il n'est pas optionnel). Choisis-en une.


HOST_TAG_NAME : lire la balise sur laquelle ta directive est posée

Angular expose dans la même famille un token pour le nom de balise de l'élément host :

readonly tag = inject(HOST_TAG_NAME);  // 'section' sur <section appTag>

Pratique pour une directive dont le comportement dépend de l'élément qui la porte : typiquement, avertir en dev qu'un appButton est posé sur un <div> plutôt que sur un <button>. Il accepte le même { optional: true }. Attention à la version : contrairement à HostAttributeToken (17.3), il n'existe qu'à partir d'Angular 18.


Récap actionnable

Besoin Outil
Valeur qui change après l'init input()
Valeur statique de configuration, écrite par le consommateur sur l'élément host inject(new HostAttributeToken('x'), { optional: true })
Même chose, absence = bug de programmation même code sans optional
Attribut lu depuis un composant créé dynamiquement ni la version non optionnelle ni l'optionnelle : input() + setInput()
Configuration partagée par plusieurs composants InjectionToken classique
Nom de la balise host HOST_TAG_NAME (Angular 18+)

Les réflexes à emporter :

  1. En migrant depuis @Attribute(), pars de { optional: true }, et ne retire l'option que sur les attributs dont tu as vérifié la présence dans tous les templates appelants. L'inversion du contrat d'erreur est la seule chose que le compilateur ne te dira pas.
  2. Écris tes attributs en kebab-case. La sensibilité à la casse du token est réelle et invisible dans les devtools.
  3. Dès que la valeur peut arriver autrement que par un attribut écrit en dur (liaison, interpolation, création dynamique), ce n'est plus un attribut host : c'est un input().

Le token n'est pas juste un @Attribute() repeint pour l'ère du inject(). Il transforme la lecture d'un attribut en fonction appelable, donc en brique réutilisable dans une factory, une host directive ou un helper maison. C'est ce qui vaut vraiment le déplacement, une fois le piège du contrat d'erreur passé.

📧 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.