My components have no decorators left
input(), model() and output() replaced @Input/@Output in everything I write — but the win isn't cosmetic. Required inputs the compiler enforces, transforms at the boundary, two-way binding that isn't a naming convention.
I noticed it during a code review last month: the component on my screen had no decorators below the @Component line. No @Input(), no @Output(), no @ViewChild. Just functions. And the strange part is that nobody on the team had decided this — it had simply become how we write components, one small API at a time.
export class UserCard {
user = input.required<User>();
compact = input(false, { transform: booleanAttribute });
expanded = model(false);
deleted = output<string>();
}Four lines, four different upgrades hiding in them.
required, enforced by someone who never sleeps
The old world: every @Input() was implicitly optional, so a forgotten binding compiled fine and surfaced as undefined at runtime — usually in the one template path QA didn't click. We defended with runtime asserts and review checklists. input.required<User>() moves that defense into the compiler: use the component without binding user and the build fails. A whole genre of bug, reclassified from "incident" to "red squiggle". This single feature justifies the migration on its own.
Transforms: normalize at the door
<user-card compact /> — no value, just the attribute. With a plain input that's the string "", which is falsy-ish but not false, and we've all debugged that special hell. transform: booleanAttribute converts at the boundary, once, declaratively. Custom transforms do the same for your own messy edges (trimming strings, clamping numbers) — the component body only ever sees clean values. It's input validation as a type signature instead of defensive code scattered through methods.
Inputs are signals, so the graph reaches them
The structural change: an input is now a node in the reactive graph. Deriving from it is just computed():
user = input.required<User>();
initials = computed(() =>
this.user().name.split(' ').map(w => w[0]).join(''),
);Compare with what this used to take: ngOnChanges, the SimpleChanges bag with its stringly-typed keys, a manually maintained derived field, and the bug where you forgot to handle the first change. The lifecycle hook still exists, but I genuinely cannot remember the last time I wrote it.
model(): two-way binding stops being a convention
Banana-in-a-box always worked by naming convention — an input x plus an output xChange, stitched together by the compiler if you spelled both right. model(false) declares the actual thing: one writable signal, readable and settable inside the component, two-way bindable outside with [(expanded)]. Parent passes a signal, they stay in sync; parent passes a static value, it still works one-way. The convention became a primitive, and primitives don't have typos.
output(): smaller than it looks
output() reads like a rename of EventEmitter, but it quietly fixes an old wart: EventEmitter extended RxJS Subject, so people piped it, subscribed to it, and coupled component contracts to an implementation detail that was never meant to be public API. output() exposes emit() and nothing else — the contract is the contract. (If you need an output derived from a stream, outputFromObservable() covers the interop deliberately rather than accidentally.)
Migrating is mostly free
ng generate @angular/core:signal-inputs
ng generate @angular/core:output-migrationThe schematics handle the mechanical rewrite, including call sites — remember inputs become functions, so templates and code read user() not user. What the schematic can't do is the thinking: which inputs should have been required all along, which booleans deserve a transform, which input/output pair was secretly a model(). That pass, done by hand, is where the API of your components actually improves — the decorators leaving is just the visible side effect.