| |

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.

CategoryDecoratorsStatus
Class@Component, @Directive, @Injectable, @NgModule, @PipeActive
Input/Output@Input, @OutputLegacy (signal replacements)
Query@ViewChild, @ViewChildren, @ContentChild, @ContentChildrenLegacy (signal replacements)
Host@HostBinding, @HostListenerLegacy (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:

DecoratorSignal replacement
@Input() name: stringname = input<string>()
@Input({ required: true }) name: stringname = input.required<string>()
@Output() selected = new EventEmitter<T>()selected = output<T>()
@ViewChild('ref') ref!: ElementRefref = viewChild<ElementRef>('ref')
@ContentChild('ref') ref!: ElementRefref = contentChild<ElementRef>('ref')
@HostBinding('class.x') x: booleanhost: { '[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

DecoratorSignal Function
@Input() x: Tx = input<T>()
@Input({ required: true }) x: Tx = input.required<T>()
@Output() x = new EventEmitter<T>()x = output<T>()
@ViewChild('ref') x: Tx = viewChild<T>('ref')
@ContentChild('ref') x: Tx = contentChild<T>('ref')
@HostBinding('class.x') xhost: { '[class.x]': 'x()' }
@HostListener('click') f()host: { '(click)': 'f()' }

The Compiler Options

OptionPurpose
experimentalDecoratorsEnable the legacy decorators
emitDecoratorMetadataEnable the DI metadata
annotationsAsThe emission mode

The Decorators That Remain

DecoratorPurpose
@ComponentDeclare the component
@DirectiveDeclare the directive
@InjectableRegister the service
@PipeDeclare the pipe
@NgModuleDeclare the module

The Migration’s Tools

ToolPurpose
ngxtension:convert-signal-inputsThe automatic’s
The manual’sThe 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

PitfallProblemSolution
The missing flagThe legacy errorThe experimentalDecorators
The ngOnChanges with the signalThe no-triggerThe effect
The missing () in the templateThe function’sThe name()
The @HostBinding with the signalThe no-effectThe host object
The missing emitDecoratorMetadataThe DI failureThe flag
The mixed stylesThe confusionThe 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

ItemValue
The legacy flagexperimentalDecorators: true
The metadata flagemitDecoratorMetadata: true
The Angular optionsangularCompilerOptions
The annotationsAsstatic 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 experimentalDecorators and emitDecoratorMetadata flags — the two are in the compilerOptions, and the emitDecoratorMetadata powers the DI’s constructor parameter resolution
  • The @Input and @Output decorators are replaced by the input() and output() functions — the signal functions are more type-safe, with no ! assertion and no undefined in the required input’s type
  • The @ViewChild and @ContentChild decorators are replaced by the viewChild() and contentChild() functions — the signal queries return the typed signals, and the .required variant removes the undefined
  • The @HostBinding and @HostListener decorators are replaced by the host object — the host object uses the template syntax and reads the signals with the () call
  • The ngOnChanges does not fire for the signal inputs — the effect() in the constructor is the replacement for the input’s change’s reaction
  • The @Component, @Directive, @Injectable, @Pipe, and @NgModule decorators remain — they declare what the class is, and the signal migration changed the data’s flow’s APIs, not the declaration’s
  • The angularCompilerOptions‘s annotationsAs controls the emission — the static fields is the default and allows the tree-shaking, and the decorators is 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 OnPush change detection — the signals integrate with the OnPush by 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!