disabled finally means disabled: input transforms
Writing <app-button disabled> used to pass the string 'disabled' into a boolean input, and half of Angular's UI libraries carried coercion getters to cope. booleanAttribute and numberAttribute retire the whole ritual.
Here's a bug that has existed in every Angular design system I've touched. You build a button with a boolean input, someone uses it the way HTML has trained them to for thirty years:
<app-button disabled>Save</app-button>And the button stays clickable. Because without brackets that's not a boolean binding — it's a static attribute, and static attributes bind the string "". Even better: disabled="false" passes the string "false", which is truthy, so the button that was told false renders disabled. The type system said boolean; the DOM had other plans.
For years the workaround was the coercion ritual — a getter/setter pair with coerceBooleanProperty from the CDK on every single boolean input, eight lines of ceremony per flag, so pervasive that the CDK shipped BooleanInput types just to annotate the pattern. The framework finally absorbed the ritual:
export class ButtonComponent {
disabled = input(false, { transform: booleanAttribute });
tabIndex = input(0, { transform: numberAttribute });
}A transform is a function that runs on every value the input receives, before it lands. booleanAttribute implements the HTML attribute semantics you actually wanted: absent is false, present-but-empty is true, and real booleans pass through. numberAttribute does the string → number conversion, so tabindex="3" from a template arrives as the number 3. Both work identically on decorator inputs — @Input({ transform: booleanAttribute }) — if you're not on signal inputs yet.
The type-level trick
The clever part is that a transform splits the input's type in two. The template side may now pass string | boolean — the template type checker knows about the transform and accepts disabled, disabled="false" and [disabled]="expr" alike. The class side reads a clean boolean, always, because everything funnels through the coercion. Write-type and read-type diverge, and both are checked. That's the exact contract the getter/setter pattern was hand-rolling, now expressed in one option and visible to the compiler.
Custom transforms
The built-ins cover the attribute cases; the option takes any function of the right shape:
function trimmed(v: string | null | undefined): string {
return (v ?? '').trim();
}
export class SearchBoxComponent {
query = input('', { transform: trimmed });
}Normalizing whitespace, clamping a number into a range, defaulting null to a sentinel — anything that answers "what shape should this value have once it's mine." The constraint to respect: the transform must be a standalone, statically analyzable function. A top-level function or a static method works; an inline arrow that closes over this does not, and the compiler will tell you so. Transforms also run on every write, including setInput from dynamic-component code, so keep them cheap and pure — a transform with side effects is an input that changes behavior depending on how often change detection looked at it.
Normalization, not validation
The temptation once you have a function hook is to validate in it — throw on out-of-range, reject bad enum strings. Resist. A throw inside a transform surfaces as a change-detection error pointing vaguely at the parent template, which is miserable error locality for the consumer. Transforms are for making sloppy-but-well-meaning input shapes correct ("3" → 3); actual invariants belong where failures can be reported properly — a computed() that clamps, an effect that warns in dev mode, a form validator.
It's a small feature. It deleted more lines from our component library than any small feature has a right to — and it closed the gap between how our components claimed to be used and how HTML users were always going to use them. <app-button disabled> does what it says now. It only took a decade.