TypeScript 79 🔷 Decorators in Angular
Angular is the framework that made decorators mainstream in the TypeScript ecosystem. The @Component, @Directive, @Injectable, and @NgModule decorators are the framework’s building blocks, and they were built on the legacy experimental decorators covered in TypeScript 77. But Angular has been migrating. The signal-based APIs introduced in Angular 16 — input(), output(), viewChild() — are replacing the decorator equivalents, and the framework is moving toward the Stage 3 standard. This chapter covers what Angular decorators do, the legacy implementations that still power most applications, the signal-based replacements, the migration path, and the specific decorators that remain essential even in modern Angular.
Key point: Angular’s legacy decorators — @Component, @Directive, @Injectable, @Input, @Output, @ViewChild, @HostBinding, @HostListener — rely on experimentalDecorators: true and emitDecoratorMetadata: true in tsconfig.json . The signal-based functions — input(), output(), viewChild(), contentChild() — replace the decorator equivalents with a function-based API that is more type-safe and works natively with signals . Angular 21+ still supports both, but the direction is clear: signals are the future, and decorators are legacy . The @Component and @Directive decorators themselves remain — even signal-based components use @Component — but the input, output, and query decorators are being phased out .
The Angular decorator categories
Angular decorators fall into several categories based on what they decorate and what they do.
| Category | Decorators | Status |
|---|---|---|
| Class | @Component, @Directive, @Injectable, @NgModule, @Pipe | Active |
| Input/Output | @Input, @Output | Legacy (signal replacements) |
| Query | @ViewChild, @ViewChildren, @ContentChild, @ContentChildren | Legacy (signal replacements) |
| Host | @HostBinding, @HostListener | Legacy (host object replacement) |
Why @Component remains. Even in Angular 21 with signal-based inputs and outputs, the @Component decorator is still the way to declare a component. It carries the selector, template, styles, imports, and change detection strategy . The signal migration changed how inputs and outputs are defined, not how components are declared.
Why the input and output decorators are being replaced. The @Input() decorator requires the ! assertion or a default value because TypeScript’s strictPropertyInitialization cannot know that Angular will assign the value . The input() function solves this: input.required<T>() produces a Signal<T> with no undefined in the type, and input<T>() produces a Signal<T | undefined>. The type safety is native to the function API in a way it never was for decorators .
The legacy decorator syntax
The legacy decorators use the property decorator and method decorator syntax from TypeScript 77. Understanding them matters because existing Angular codebases still use them heavily.
@Component({
selector: 'app-user-card',
template: `<p>{{ user.name }}</p>`,
})
export class UserCardComponent {
@Input() user!: User;
@Output() selected = new EventEmitter<User>();
select(): void {
this.selected.emit(this.user);
}
}
The @Input() decorator marks the user property as an input, and the ! assertion tells TypeScript that Angular will assign it before use. The @Output() decorator marks the selected property as an output, and the EventEmitter emits the value to the parent .
Why the ! is necessary. With strictPropertyInitialization enabled, TypeScript requires every class property to be initialized in the constructor or have a default value. The @Input() property is assigned by Angular after construction, which the compiler cannot know about. The ! assertion suppresses the error, but it also removes the type safety — the compiler no longer checks whether the property is actually assigned .
Why the EventEmitter is the output mechanism. The legacy @Output() uses EventEmitter to emit values. The parent binds with (selected)="onSelect($event)". The EventEmitter is a subtype of RxJS Subject, and the binding subscribes to it internally .
The signal-based replacements
The signal-based functions replace the decorator equivalents. They are functions, not decorators, and they produce signals rather than plain properties.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `<p>{{ user().name }}</p>`,
})
export class UserCardComponent {
user = input.required<User>();
selected = output<User>();
select(): void {
this.selected.emit(this.user());
}
}
The user = input.required<User>() produces a Signal<User>, and the template reads user() . The selected = output<User>() produces an OutputEmitterRef<User>, and the emit method sends the value. There is no ! assertion, no undefined in the type, and the template syntax is the signal read .
Why the signal input is more type-safe. The input.required<T>() return type is InputSignal<T>, which is assignable to Signal<T>. The compiler knows the value is present, and there is no undefined to handle. The input<T>() return type is InputSignal<T | undefined>, which is accurate: the input may or may not be provided .
Why the signal output is more type-safe. The output<T>() return type is OutputEmitterRef<T>, and the emit method accepts a T. The parent’s binding receives a T. The EventEmitter was an any-typed Subject under the hood; the OutputEmitterRef is typed end to end .
Why the signal query is more type-safe. The viewChild('ref') function returns a Signal<ElementRef | undefined>, and the viewChild.required('ref') returns a Signal<ElementRef> with no undefined . The @ViewChild decorator required the ! assertion or the AfterViewInit lifecycle.
The host binding migration
The @HostBinding and @HostListener decorators are replaced by the host object in the @Component or @Directive decorator .
// Legacy:
@HostBinding('class.active') isActive = false;
@HostListener('click')
onClick(): void { ... }
// Modern:
@Component({
host: {
'[class.active]': 'isActive()',
'(click)': 'onClick()',
},
})
The host object uses the same template syntax as the component template, and it reads signals with the () call . The decorators are not deprecated yet, but the host object is the recommended pattern.
The Angular compiler options
Angular’s compiler has its own options in tsconfig.json, separate from the TypeScript compiler options. The angularCompilerOptions object is a sibling to compilerOptions .
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"angularCompilerOptions": {
"annotationsAs": "static fields"
}
}
The experimentalDecorators and emitDecoratorMetadata flags are required for the legacy decorators . The annotationsAs option controls how Angular-specific annotations are emitted: the default static fields replaces decorators with static fields for better tree-shaking, and decorators leaves the decorators in place .
Why emitDecoratorMetadata matters for Angular’s DI. Angular’s dependency injection reads the constructor parameter types from the emitted metadata. When @Injectable() is applied, the compiler emits design:paramtypes metadata that the DI container reads to resolve dependencies . Without the metadata, constructor injection fails.
Why the static fields default matters for bundle size. The static fields emission allows advanced tree-shakers like Closure Compiler to remove unused Angular classes from the bundle. The decorators emission leaves the __decorate helper calls, which the tree-shaker cannot remove as easily .
The migration path
The migration from decorators to signals is incremental. Angular provides schematics and the ngxtension package provides migration tools .
The manual migration steps:
| Decorator | Signal replacement |
|---|---|
@Input() name: string | name = input<string>() |
@Input({ required: true }) name: string | name = input.required<string>() |
@Output() selected = new EventEmitter<T>() | selected = output<T>() |
@ViewChild('ref') ref!: ElementRef | ref = viewChild<ElementRef>('ref') |
@ContentChild('ref') ref!: ElementRef | ref = contentChild<ElementRef>('ref') |
@HostBinding('class.x') x: boolean | host: { '[class.x]': 'x()' } |
@HostListener('click') onClick() | host: { '(click)': 'onClick()' } |
The automated migration. The ngxtension package provides a schematic that converts @Input() and @Output() decorators to their signal equivalents . The schematic updates the component class and the template, but it may require manual adjustments for required inputs and type narrowing.
Why the migration requires ngOnChanges changes. The ngOnChanges lifecycle hook does not fire for signal inputs. A component that used ngOnChanges to react to input changes must use an effect() in the constructor instead . The effect reads the signal input and runs the reaction logic.
What remains decorator-based
Even with the signal migration, the @Component, @Directive, @Injectable, @Pipe, and @NgModule decorators remain. They declare what the class is, not how its data flows.
Why @Component is still the declaration mechanism. The signal-based component still uses @Component to declare the selector, template, styles, and imports. The changeDetection: ChangeDetectionStrategy.OnPush is still set in the decorator . The signal migration changed the input/output/query APIs, not the component declaration.
Why @Injectable is still the DI mechanism. The @Injectable({ providedIn: 'root' }) decorator is still how a service is registered with the DI container. The inject() function replaced constructor parameter injection, but the @Injectable decorator is still how the class is marked as injectable .
Why @NgModule is legacy but supported. The @NgModule decorator is the module system’s declaration. With standalone components, most new applications do not use NgModules, but the decorator remains for backward compatibility and for the applications that still use the module pattern.
Complete Example Session
// ============================================
// PART 1: THE TSCONFIG FOR LEGACY DECORATORS
// ============================================
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2022"
},
"angularCompilerOptions": {
"annotationsAs": "static fields"
}
}
// ============================================
// PART 2: THE LEGACY COMPONENT
// ============================================
@Component({
selector: 'app-legacy',
template: `<p>{{ name }}</p><button (click)="select()">Select</button>`,
})
export class LegacyComponent {
@Input() name!: string;
@Output() selected = new EventEmitter<string>();
select(): void {
this.selected.emit(this.name);
}
}
// ============================================
// PART 3: THE SIGNAL COMPONENT
// ============================================
@Component({
selector: 'app-signal',
template: `<p>{{ name() }}</p><button (click)="select()">Select</button>`,
})
export class SignalComponent {
name = input.required<string>();
selected = output<string>();
select(): void {
this.selected.emit(this.name());
}
}
// ============================================
// PART 4: THE HOST BINDING
// ============================================
// Legacy:
@Component({ ... })
export class LegacyDirective {
@HostBinding('class.active') isActive = false;
@HostListener('click')
onClick(): void { ... }
}
// Modern:
@Component({
host: {
'[class.active]': 'isActive()',
'(click)': 'onClick()',
},
})
export class ModernDirective {
isActive = signal(false);
onClick(): void { ... }
}
// ============================================
// PART 5: THE QUERY
// ============================================
// Legacy:
@ViewChild('input') input!: ElementRef<HTMLInputElement>;
// Modern:
input = viewChild<ElementRef<HTMLInputElement>>('input');
// ============================================
// PART 6: THE N GON CHANGES MIGRATION
// ============================================
// Legacy:
ngOnChanges(changes: SimpleChanges): void {
if (changes['name']) { ... }
}
// Modern:
constructor() {
effect(() => {
const name = this.name();
// the reaction's
});
}
// ============================================
// PART 7: THE INJECTABLE
// ============================================
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
}
// ============================================
// PART 8: THE ANNOTATIONSAS
// ============================================
// angularCompilerOptions
{
"annotationsAs": "static fields" // the default's, the tree-shaking's
// "annotationsAs": "decorators" // the faster's, the no-tree-shaking's
}
// ============================================
// PART 9: THE MIGRATION SCHEMATIC
// ============================================
// ng g ngxtension:convert-signal-inputs
// ============================================
// PART 10: WHAT NOT TO DO
// ============================================
// Don't forget the experimentalDecorators flag for the legacy
@Input() name!: string; // ❌ without the flag
// Don't use the signal input with the ngOnChanges
ngOnChanges() { ... } // ❌ the signal input does not trigger it
// Don't use the @HostBinding with the signal component
@HostBinding('class.x') x: boolean; // ❌ the host object is the modern's
// Don't forget the () in the template for the signal input
{{ name }} // ❌ the signal's is name()
// Don't mix the legacy and the signal without the reason
// The two are the different, and the different is the confusion.
Quick Reference
The Decorator to Signal Mapping
| Decorator | Signal Function |
|---|---|
@Input() x: T | x = input<T>() |
@Input({ required: true }) x: T | x = input.required<T>() |
@Output() x = new EventEmitter<T>() | x = output<T>() |
@ViewChild('ref') x: T | x = viewChild<T>('ref') |
@ContentChild('ref') x: T | x = contentChild<T>('ref') |
@HostBinding('class.x') x | host: { '[class.x]': 'x()' } |
@HostListener('click') f() | host: { '(click)': 'f()' } |
The Compiler Options
| Option | Purpose |
|---|---|
experimentalDecorators | Enable the legacy decorators |
emitDecoratorMetadata | Enable the DI metadata |
annotationsAs | The emission mode |
The Decorators That Remain
| Decorator | Purpose |
|---|---|
@Component | Declare the component |
@Directive | Declare the directive |
@Injectable | Register the service |
@Pipe | Declare the pipe |
@NgModule | Declare the module |
The Migration’s Tools
| Tool | Purpose |
|---|---|
ngxtension:convert-signal-inputs | The automatic’s |
| The manual’s | The by-hand’s |
Best Practices
✅ Do This:
// Enable the flags for the legacy
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
} // ✅
// Use the signal input for the new components
name = input.required<string>(); // ✅
// Use the signal output
selected = output<string>(); // ✅
// Use the host object
host: { '[class.active]': 'isActive()' } // ✅
// Use the effect for the input changes
effect(() => { const name = this.name(); }); // ✅
❌ Don’t Do This:
// Don't forget the flag for the legacy
@Input() name!: string; // ❌ without the flag // ⚠️
// Don't use the ngOnChanges with the signal input
ngOnChanges() { ... } // ❌ the signal does not trigger it // ⚠️
// Don't forget the () in the template
{{ name }} // ❌ the signal's is name() // ⚠️
// Don't mix the legacy and the signal
@Input() a!: string; a2 = input<string>(); // ❌ the confusion // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| The missing flag | The legacy error | The experimentalDecorators |
The ngOnChanges with the signal | The no-trigger | The effect |
The missing () in the template | The function’s | The name() |
The @HostBinding with the signal | The no-effect | The host object |
The missing emitDecoratorMetadata | The DI failure | The flag |
| The mixed styles | The confusion | The one or the other |
Real-World Examples
1. The legacy input
@Input() name!: string;
2. The signal input
name = input.required<string>();
3. The legacy output
@Output() selected = new EventEmitter<string>();
4. The signal output
selected = output<string>();
5. The legacy query
@ViewChild('input') input!: ElementRef;
6. The signal query
input = viewChild<ElementRef>('input');
7. The host binding
host: { '[class.active]': 'isActive()' }
8. The effect
effect(() => { const name = this.name(); });
9. The injectable
@Injectable({ providedIn: 'root' })
10. The migration
ng g ngxtension:convert-signal-inputs
Visual: The Decorator vs the Signal
┌──────────────────────────────────────────────┐
│ THE LEGACY DECORATOR'S │
│ @Input() name!: string; │
│ The ! is the assertion's │
│ The string's is the type's │
│ The undefined's is the possibility's │
│ │
├──────────────────────────────────────────────┤
│ THE SIGNAL FUNCTION'S │
│ name = input.required<string>(); │
│ The no !'s │
│ The string's is the type's │
│ The undefined's is the gone's │
│ │
│ The signal's is the type-safe's. │
│ │
└──────────────────────────────────────────────┘
Visual: The Angular Compiler Options
┌──────────────────────────────────────────────┐
│ compilerOptions │
│ experimentalDecorators: true │
│ emitDecoratorMetadata: true │
│ │
├──────────────────────────────────────────────┤
│ angularCompilerOptions │
│ annotationsAs: "static fields" (the default's)│
│ annotationsAs: "decorators" (the fast's) │
│ │
│ The static fields's is the tree-shaking's. │
│ │
└──────────────────────────────────────────────┘
Visual: The Migration’s
┌──────────────────────────────────────────────┐
│ THE LEGACY'S │
│ @Input() name!: string; │
│ @Output() selected = new EventEmitter(); │
│ @ViewChild('ref') ref!: ElementRef; │
│ │
│ │ The migration's │
│ ▼ │
│ │
│ THE SIGNAL'S │
│ name = input.required<string>(); │
│ selected = output<string>(); │
│ ref = viewChild<ElementRef>('ref'); │
│ │
│ The ngOnChanges's is the effect's. │
│ │
└──────────────────────────────────────────────┘
Visual: The Decorators That Remain
┌──────────────────────────────────────────────┐
│ THE REMAIN'S │
│ @Component → the declaration's │
│ @Directive → the declaration's │
│ @Injectable → the DI's │
│ @Pipe → the declaration's │
│ @NgModule → the legacy's │
│ │
├──────────────────────────────────────────────┤
│ THE REPLACED'S │
│ @Input → input() │
│ @Output → output() │
│ @ViewChild → viewChild() │
│ @HostBinding → the host object's │
│ @HostListener→ the host object's │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| The legacy flag | experimentalDecorators: true |
| The metadata flag | emitDecoratorMetadata: true |
| The Angular options | angularCompilerOptions |
The annotationsAs | static fields (the default’s) |
| The input | @Input → input() |
| The output | @Output → output() |
| The query | @ViewChild → viewChild() |
| The host | @HostBinding → the host object |
The ngOnChanges | → effect() |
| The remain | @Component, @Directive, @Injectable |
Key takeaways:
- Angular’s legacy decorators require the
experimentalDecoratorsandemitDecoratorMetadataflags — the two are in thecompilerOptions, and theemitDecoratorMetadatapowers the DI’s constructor parameter resolution - The
@Inputand@Outputdecorators are replaced by theinput()andoutput()functions — the signal functions are more type-safe, with no!assertion and noundefinedin the required input’s type - The
@ViewChildand@ContentChilddecorators are replaced by theviewChild()andcontentChild()functions — the signal queries return the typed signals, and the.requiredvariant removes theundefined - The
@HostBindingand@HostListenerdecorators are replaced by thehostobject — thehostobject uses the template syntax and reads the signals with the()call - The
ngOnChangesdoes not fire for the signal inputs — theeffect()in the constructor is the replacement for the input’s change’s reaction - The
@Component,@Directive,@Injectable,@Pipe, and@NgModuledecorators remain — they declare what the class is, and the signal migration changed the data’s flow’s APIs, not the declaration’s - The
angularCompilerOptions‘sannotationsAscontrols the emission — thestatic fieldsis the default and allows the tree-shaking, and thedecoratorsis the faster but the no-tree-shaking - The migration is incremental and tool-assisted — the
ngxtension‘s schematic converts the inputs and the outputs, and the manual’s adjustments are the required’s and the type’s narrowing’s - The signal-based components use the
OnPushchange detection — the signals integrate with theOnPushby marking the component dirty when the signal read in the template updates - The two styles can coexist — the legacy and the signal are the both’s, and the migration’s is the progressive’s
Remember: Angular is the framework that brought the decorators to the TypeScript’s mainstream, and it is also the framework that is migrating away from them. The @Component and the @Directive remain, but the @Input, the @Output, the @ViewChild, the @HostBinding, and the @HostListener are being replaced by the signal’s functions. The experimentalDecorators and the emitDecoratorMetadata are the legacy’s flags, and the input(), the output(), the viewChild(), and the host object are the modern’s. The migration is the incremental’s, and the signals are the future’s.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!