~9 min de lecture

Le piège de disabled="false" en Angular : booleanAttribute et numberAttribute sauvent tes signal inputs

Tu écris un composant AppButton avec un signal input disabled :

disabled = input(false);

Un collègue l'utilise depuis un composant hôte qui pilote AppButton dynamiquement (un wrapper générique, un renderer de formulaire construit depuis une config JSON, peu importe), via ComponentRef.setInput() :

hostRef.setInput('disabled', 'false');

Il t'ouvre un ticket : "Le bouton est grisé alors que j'ai passé false." Tu regardes ton composant, tout te semble correct. Et ça compile : setInput(name: string, value: unknown) accepte n'importe quoi en second paramètre, TypeScript n'a rien à vérifier ici. Les tests unitaires passent aussi, parce qu'ils utilisent presque toujours le binding [disabled]="...", qui lui est bien typé. En prod, le bouton reste grisé. Pas parce que ton code est faux, mais parce que HTML - et par ricochet setInput(), qui reçoit exactement ce qu'un attribut HTML aurait donné - ne connaît qu'un seul type pour une entrée non vérifiée : la string. Et la string 'false' est truthy en JavaScript.

Depuis Angular 17.1, la parade est native, courte et systématique : deux fonctions helper (booleanAttribute et numberAttribute) passées en option transform à ton input(). La doc les mentionne en une ligne. Ce qui suit couvre les raisons d'en faire un réflexe, les trois cas de figure où le comportement est plus subtil que ce que le nom laisse croire, et le moment précis où il faut écrire ton propre transform.

Valide Angular 17.1+ (introduction des signal inputs avec option transform). booleanAttribute et numberAttribute existent depuis Angular 16.1 et fonctionnent aussi avec l'API décorateur @Input({ transform }).


TL;DR

Cas Sans transform Avec booleanAttribute
<my-btn disabled> reçoit '' (empty string, falsy en JS) reçoit true
<my-btn disabled=""> reçoit '' (falsy) reçoit true
<my-btn disabled="false"> reçoit 'false' (truthy, string non vide) reçoit false
<my-btn disabled="true"> reçoit 'true' (truthy) reçoit true
<my-btn [disabled]="false"> reçoit false (bool) reçoit false
absent reçoit la valeur par défaut reçoit la valeur par défaut

Sans transform, le bug ne va pas dans un seul sens : <my-btn disabled> (attribut nu, censé vouloir dire "actif") ne désactive pas ton bouton, puisque '' est falsy en JS, alors que disabled="false" (qui devrait vouloir dire "inactif") le désactive bel et bien. booleanAttribute corrige les deux sens à la fois, en réalignant sur la sémantique HTML native où seule la présence de l'attribut compte.

Trois lignes à retenir :

  1. Tout input() typé boolean qui peut recevoir une valeur non vérifiée par le compilateur de template (voir plus bas) doit avoir transform: booleanAttribute.
  2. Tout input() typé number dans la même situation doit avoir transform: numberAttribute, avec une valeur de repli passée en fonction fléchée ((v) => numberAttribute(v, repli)) pour éviter un NaN silencieux sur une entrée non numérique.
  3. input.required accepte transform avec la même syntaxe, mais le paramètre générique change.

Reproduire le bug en 20 secondes

Un AppButton minimal, standalone (par défaut en Angular 19+), OnPush comme il faut :

import { ChangeDetectionStrategy, Component, effect, input } from '@angular/core';

@Component({
  selector: 'app-button',
  template: `
    <button [disabled]="disabled()" (click)="clicked()">
      <ng-content />
    </button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppButton {
  disabled = input(false);

  clicked() {
    console.log('click');
  }
}

Le point d'entrée le plus direct pour reproduire, c'est setInput() - son second paramètre est typé unknown et échappe à toute vérification :

const ref = viewContainerRef.createComponent(AppButton);
ref.setInput('disabled', 'false');

Le même bug apparaît aussi depuis un template statique, mais uniquement si ton tsconfig.json désactive strictTemplates (projets historiques, ou migrés depuis d'anciennes versions d'Angular - ng new l'active par défaut depuis longtemps, et Angular 22 l'impose désormais par défaut, strictTemplates: false explicite ou pas) :

<!-- compile uniquement si strictTemplates est désactivé -->
<app-button disabled="false">Envoyer</app-button>
<app-button disabled="true">Bloqué</app-button>

Sur un projet strict, le même markup est refusé au build :

TS2322: Type 'string' is not assignable to type 'boolean'.

Une bonne nouvelle à moitié : le compilateur attrape la faute à la source pour tout ce qui passe par un template compilé en AOT. Il ne peut rien pour setInput().

Dans les deux scénarios qui compilent (setInput(), ou template en strictTemplates: false), tu obtiens deux boutons grisés au lieu d'un actif et un désactivé. Ouvre les devtools : les deux <button> natifs ont l'attribut disabled.

Rajoute un console.log :

constructor() {
  effect(() => console.log('disabled=', this.disabled(), typeof this.disabled()));
}

Console :

disabled= false string    // pour disabled="false" (via setInput ou template non strict)
disabled= true string     // pour disabled="true"

TypeScript n'a rien vérifié sur aucun des deux chemins. Le signal input est typé boolean mais contient une string. Le template [disabled]="disabled()" reçoit 'false', une string non vide, donc truthy, donc l'attribut natif disabled est bien posé sur le bouton.

C'est le moment où beaucoup de devs plongent dans une chasse au bug côté template. La racine est ailleurs.


Pourquoi HTML impose ce piège

Les attributs HTML sont toujours des strings. Angular n'y peut rien : quand tu écris disabled="false" sans crochets, tu écris un attribut HTML statique, pas une expression TypeScript. Le parseur du template lit ta valeur comme une string et la passe telle quelle au binding - sauf que sur un projet avec strictTemplates actif, le compilateur refuse d'assigner cette string à un input typé boolean, comme on vient de le voir. setInput() n'a pas cette protection : son second paramètre est typé unknown, donc n'importe quelle string y transite sans jamais croiser le vérificateur de type de template.

Avec des crochets, tout change : [disabled]="false" est une expression TypeScript évaluée. Le false est un booléen. Là, ton signal input reçoit bien un booléen.

Le drame : les tests unitaires écrits en TypeScript utilisent presque toujours le binding avec crochets, qui passe un vrai booléen - le bug n'y apparaît donc jamais. Il attend un consommateur qui appelle setInput() avec une valeur non typée (un champ lu depuis une config JSON, un formulaire dynamique, un CMS) ou un projet qui tourne avec strictTemplates: false.

Cette tolérance aux strings, le navigateur l'applique lui aussi à l'attribut natif disabled d'un <button> : disabled="", disabled="disabled", disabled="false" sont tous équivalents à "présent, donc désactivé". Le seul moyen de désactiver la désactivation, c'est de retirer l'attribut. booleanAttribute reproduit cette sémantique côté Angular.


booleanAttribute : le transform à câbler par défaut

Une seule option en plus :

import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core';

@Component({
  selector: 'app-button',
  template: `
    <button [disabled]="disabled()" (click)="clicked()">
      <ng-content />
    </button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppButton {
  disabled = input(false, { transform: booleanAttribute });

  clicked() {
    console.log('click');
  }
}

Maintenant disabled="false" donne un bouton actif, que la valeur arrive via setInput() ou via un template strictTemplates: false. <app-button disabled> donne un bouton grisé. La sémantique est alignée sur celle des attributs natifs HTML.

Sous le capot, booleanAttribute fait l'équivalent de :

function booleanAttribute(value: unknown): boolean {
  return typeof value === 'boolean' ? value : (value != null && value !== 'false');
}

Traduction : tout ce qui n'est ni null, ni undefined, ni la string 'false' devient true - et un booléen déjà présent traverse tel quel (booleanAttribute(false) === false).

Un piège dans le piège : passer un booléen depuis TS fonctionne comme prévu. Passer 0 (number) donne true. Passer NaN donne true. booleanAttribute traite tout ce qui n'est pas explicitement 'false' ou nullish comme une présence, à l'image de HTML. Si tu as besoin d'une conversion "JavaScript truthy" (où 0 devient false), tu dois écrire ton propre transform. Voir plus bas.


numberAttribute : même logique, en numérique

Le pendant pour les inputs numériques :

import { ChangeDetectionStrategy, Component, input, numberAttribute } from '@angular/core';

@Component({
  selector: 'app-pagination',
  template: `<span>Page {{ current() }} / {{ total() }}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Pagination {
  current = input(1, { transform: numberAttribute });
  total = input(1, { transform: numberAttribute });
}

Sans transform, <app-pagination current="3" total="10"> te donne current() === '3' (string) et un affichage qui marche par coïncidence tant que tu n'essaies pas de faire de l'arithmétique. current() + 1 te renvoie '31'. Bienvenue en JavaScript.

Avec numberAttribute, tu obtiens 3 et 10, typés number.

Le comportement en cas d'échec est un piège classique :

numberAttribute('abc');    // → NaN
numberAttribute('');       // → NaN
numberAttribute(null);     // → NaN

Si tu veux une valeur de repli explicite (recommandé), passe une fonction fléchée :

current = input(1, {
  transform: (v: unknown) => numberAttribute(v, 1),
});

numberAttribute accepte un deuxième paramètre fallbackValue (par défaut NaN) que tu peux fournir dans ta propre fonction. Voir NaN s'afficher dans ton template signale un bug logique, pas une donnée valide. Prévois toujours ce cas.


Piège avancé n°1 : input.required avec transform

input.required<T>() a une seconde surcharge quand tu passes un transform : la signature devient input.required<T, TransformT>()TransformT est le type des valeurs acceptées avant transform. C'est le type que TypeScript va inférer côté parent.

import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core';

@Component({
  selector: 'app-toggle',
  template: `<span>{{ active() ? 'ON' : 'OFF' }}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Toggle {
  active = input.required({ transform: booleanAttribute });
}

Le type de active est InputSignalWithTransform<boolean, unknown> (disponible depuis Angular 17.2, quand cette surcharge s'est stabilisée) :

  • boolean : le type interne, ce que tu lis avec active().
  • unknown : le type accepté au binding parent (car booleanAttribute accepte unknown).

Ça veut dire qu'un parent peut écrire [active]="'yes'" et TypeScript ne s'y opposera pas. C'est une conséquence directe du typage de booleanAttribute, pas un bug. Si tu veux resserrer, écris ton propre transform avec une signature (value: boolean | 'true' | 'false' | '') => boolean : tu bénéficies alors du contrôle strict côté parent.


Piège avancé n°2 : renommer un input booléen sans recâbler son transform

Tu peux nommer différemment l'input côté template et côté classe avec l'option alias. Le risque n'est pas dans la combinaison des deux options - alias et transform cohabitent sans souci, dans n'importe quel ordre :

disabled = input(false, {
  alias: 'isDisabled',
  transform: booleanAttribute,
});

Il est dans le refactor. alias et transform sont deux propriétés indépendantes du même objet d'options : rien ne les lie, et rien ne t'empêche de renommer un input déjà câblé avec booleanAttribute en ne touchant qu'à alias :

// avant : disabled = input(false, { transform: booleanAttribute });
// après un renommage un peu vite fait :
disabled = input(false, { alias: 'isDisabled' });

Le transform a disparu. Le compilateur ne dit rien - alias seul est parfaitement valide. Le bug revient exactement comme au début de l'article, sur un input qui fonctionnait très bien la veille.


Piège avancé n°3 : les quatre écritures, et laquelle n'atteint jamais ton input

Compare ces quatre écritures :

Template Nature de la valeur passée
disabled string ''
disabled="false" string 'false'
[disabled]="false" booléen false
[attr.disabled]="false" attribut posé sur l'hôte, string 'false', jamais retiré

Les trois premières atterrissent sur ton signal input. La quatrième ne passe jamais par lui : [attr.*] écrit directement l'attribut DOM natif de l'élément hôte du composant (<app-button> lui-même, pas le <button> de son template interne). Piège dans le piège : [attr.disabled]="false" ne retire pas l'attribut - Angular ne retire un [attr.*] que pour null ou undefined, jamais pour false. Sans conséquence tant que l'hôte n'est pas lui-même un élément qui interprète nativement disabled, mais c'est le genre de confusion qui fait perdre une heure sur un composant qui encapsule directement un <button> natif et laisse passer certains attributs.

Règle : dès qu'un input booléen ou numérique peut recevoir une valeur via setInput() avec une source non typée, ou vivre dans un projet qui tourne en strictTemplates: false, pars du principe que la forme string sera utilisée. Câble le transform.


Le cas où booleanAttribute ne suffit pas : écrire son propre transform

On a vu plus haut que booleanAttribute traite 0 et NaN comme true - voulu, puisqu'aucun des deux n'est null ni la string 'false'. Si ton input représente un booléen "JavaScript truthy" où 0 doit au contraire devenir false, écris ton propre transform :

const truthyAttribute = (value: unknown): boolean =>
  value !== 'false' && Boolean(value);

@Component({ /* ... */ })
export class AppFlag {
  active = input(false, { transform: truthyAttribute });
}

Attention à l'effet de bord : avec ce transform, <app-flag active> (attribut nu, string '') donne active() === false, puisque Boolean('') vaut false. C'est l'inverse de la sémantique HTML native que le reste de l'article défend, et l'inverse de ce que ferait booleanAttribute sur le même markup. Assume-le consciemment si tu choisis cette voie : un attribut nu ne veut plus dire "actif".

Autre cas courant : un input qui prend une date ISO en string et que tu veux exposer en Date :

const dateAttribute = (value: unknown): Date | null => {
  if (value instanceof Date) return value;
  if (typeof value === 'string' && value !== '') {
    const parsed = new Date(value);
    return Number.isNaN(parsed.getTime()) ? null : parsed;
  }
  return null;
};

@Component({ /* ... */ })
export class EventCard {
  publishedAt = input<Date | null, unknown>(null, { transform: dateAttribute });
}

Le <Date | null, unknown> explicite dit à TypeScript : "en interne c'est Date | null, en externe on accepte n'importe quoi". C'est exactement le pattern de booleanAttribute et numberAttribute, appliqué à ton domaine.


Récap actionnable

Si tu écris un composant réutilisable au-delà de ta feature :

Type de l'input Réflexe
boolean transform: booleanAttribute par défaut
number transform: (v) => numberAttribute(v, fallback) avec fallback explicite
Date ou domaine custom transform maison retournant T | null
input consommé uniquement depuis TypeScript (jamais via un attribut HTML) pas de transform nécessaire

Le coût de mettre booleanAttribute sur un input qui n'en aurait pas besoin n'est pas nul, mais il est faible et localisé : le type interne reste boolean, seul le type accepté côté binding parent s'élargit à unknown (Piège avancé n°1) - resserrable avec ton propre transform si tu y tiens. Le coût de ne pas le mettre, lui, est de perdre l'après-midi à chasser un bug de composant qui ignore ses inputs.

Une heuristique simple pour clore le sujet : dès qu'un input booléen ou numérique est susceptible de recevoir une valeur non vérifiée par le compilateur de template (setInput(), projet en strictTemplates: false), câble le transform. C'est une option de plus dans ton input(), et une classe entière de bugs en moins.

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