TypeScriptDecorators

Decorators

Decorators are a powerful metaprogramming feature that let you attach reusable behaviour to classes, methods, properties, and parameters using a clean @decorator syntax. They are fundamental to frameworks like Angular, NestJS, TypeORM, and Inversify.

TypeScript has two decorator systems:

  • TypeScript 4 / experimentalDecorators — the legacy system, enabled with a tsconfig flag
  • TypeScript 5+ / ECMAScript Stage 3 decorators — the new standard-aligned system, enabled by default

This page covers both systems thoroughly.

TypeScript 4: experimentalDecorators

The legacy decorator system requires two tsconfig flags:

JSON
// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true  // needed for reflect-metadata (DI frameworks)
  }
}
Warning
experimentalDecorators and the new ECMAScript decorator system are mutually exclusive in TypeScript 5. If you set experimentalDecorators: true, TypeScript uses the old system. If you omit it (or set it to false), TypeScript 5 uses the new system.
Legacy Class Decorators

A class decorator is a function that receives the constructor and can return a modified constructor, a new class, or nothing (mutation).

TS
// A class decorator receives the constructor as its only argument
function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

function log(constructor: Function) {
  console.log(`Class created: ${constructor.name}`);
}

@sealed
@log
class BankAccount {
  balance = 0;
}
// Output: "Class created: BankAccount"
// BankAccount is now sealed — no new properties can be added

TS
// Decorator factory — returns a decorator (allows configuration)
function Component(options: { selector: string; template: string }) {
  return function (constructor: Function) {
    (constructor as any).selector = options.selector;
    (constructor as any).template = options.template;
    console.log(`Component registered: ${options.selector}`);
  };
}

@Component({
  selector: 'app-root',
  template: '<h1>Hello</h1>',
})
class AppComponent {
  title = 'My App';
}

console.log((AppComponent as any).selector); // 'app-root'
Legacy Method Decorators

Method decorators receive the prototype (or constructor for static methods), the method name, and a property descriptor. They are ideal for logging, caching, validation, and timing.

TS
function log(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor,
) {
  const originalMethod = descriptor.value as (...args: unknown[]) => unknown;

  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${propertyKey} with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`${propertyKey} returned:`, result);
    return result;
  };

  return descriptor;
}

function memoize(
  _target: object,
  _key: string,
  descriptor: PropertyDescriptor,
) {
  const original = descriptor.value as (...args: unknown[]) => unknown;
  const cache = new Map<string, unknown>();

  descriptor.value = function (...args: unknown[]) {
    const cacheKey = JSON.stringify(args);
    if (cache.has(cacheKey)) {
      return cache.get(cacheKey);
    }
    const result = original.apply(this, args);
    cache.set(cacheKey, result);
    return result;
  };
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }

  @memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

const calc = new Calculator();
calc.add(3, 4);
// Calling add with args: [3, 4]
// add returned: 7
Legacy Property Decorators

Property decorators receive the prototype and the property name. They cannot directly modify the property value (the descriptor is not passed), but they can attach metadata or replace the property with an accessor.

TS
// Using reflect-metadata to store validation rules
import 'reflect-metadata';

function MinLength(min: number) {
  return function (target: object, propertyKey: string) {
    const existing: Array<{ key: string; min: number }> =
      Reflect.getMetadata('validators', target) ?? [];
    Reflect.defineMetadata(
      'validators',
      [...existing, { key: propertyKey, min }],
      target,
    );
  };
}

function validate(instance: object): string[] {
  const validators: Array<{ key: string; min: number }> =
    Reflect.getMetadata('validators', Object.getPrototypeOf(instance)) ?? [];
  return validators
    .filter(({ key, min }) => {
      const value = (instance as Record<string, unknown>)[key];
      return typeof value === 'string' && value.length < min;
    })
    .map(({ key, min }) => `${key} must be at least ${min} characters`);
}

class User {
  @MinLength(3)
  name: string;

  @MinLength(8)
  password: string;

  constructor(name: string, password: string) {
    this.name = name;
    this.password = password;
  }
}

const u = new User('Al', 'pass');
console.log(validate(u));
// ['name must be at least 3 characters', 'password must be at least 8 characters']
Legacy Parameter Decorators

Parameter decorators receive the prototype, the method name, and the parameter index. They are used primarily with reflect-metadata for dependency injection.

TS
import 'reflect-metadata';

function Inject(token: string) {
  return function (target: object, _methodKey: string | undefined, paramIndex: number) {
    const existing: Array<{ index: number; token: string }> =
      Reflect.getMetadata('inject', target) ?? [];
    Reflect.defineMetadata('inject', [...existing, { index: paramIndex, token }], target);
  };
}

class AppController {
  constructor(
    @Inject('UserService') private userService: unknown,
    @Inject('AuthService') private authService: unknown,
  ) {}
}
Decorator Execution Order

When multiple decorators are applied to the same target, they execute in a specific order:

  1. Parameter decorators, then method/accessor/property decorators for each instance member (bottom-to-top)

  2. Parameter decorators, then method/accessor/property decorators for each static member (bottom-to-top)

  3. Parameter decorators for the constructor

  4. Class decorators (bottom-to-top)

TS
function classDecorator(label: string) {
  return (_: Function) => console.log(`Class: ${label}`);
}
function methodDecorator(label: string) {
  return (_: object, __: string, ___: PropertyDescriptor) =>
    console.log(`Method: ${label}`);
}

@classDecorator('C1')  // executed last (outermost)
@classDecorator('C2')  // executed second-to-last
class Example {
  @methodDecorator('M1') // applied second (outermost)
  @methodDecorator('M2') // applied first (innermost)
  greet() {}
}

// Output:
// Method: M2
// Method: M1
// Class: C2
// Class: C1
TypeScript 5: ECMAScript Stage 3 Decorators

TypeScript 5.0 introduced support for the ECMAScript Stage 3 decorator proposal — the standardised version that will eventually land in all JavaScript environments. This system has a cleaner design and does not require emitDecoratorMetadata or reflect-metadata.

Key differences from legacy decorators:

  • No experimentalDecorators flag needed
  • Decorators receive a context object instead of raw prototype/descriptor arguments
  • addInitializer allows running code at construction time
  • No support for parameter decorators yet (coming later in the spec)

TS
// TypeScript 5 — no tsconfig flags needed (experimentalDecorators must be false/absent)

// Class decorator — receives the class and a context object
function frozen<T extends new (...args: unknown[]) => object>(
  target: T,
  context: ClassDecoratorContext,
) {
  return class extends target {
    constructor(...args: unknown[]) {
      super(...args);
      Object.freeze(this);
    }
  };
}

@frozen
class Config {
  host = 'localhost';
  port = 3000;
}

const cfg = new Config();
cfg.host = 'prod.example.com'; // silently fails (frozen in strict: throws TypeError)
console.log(cfg.host); // 'localhost'
TS5 Method Decorators

TS
// Method decorator in ECMAScript decorator style
function log<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const methodName = String(context.name);

  return function (this: This, ...args: Args): Return {
    console.log(`[${methodName}] called with:`, args);
    const result = target.call(this, ...args);
    console.log(`[${methodName}] returned:`, result);
    return result;
  };
}

// Bound method decorator — ensures 'this' is always the class instance
function bound<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  context.addInitializer(function (this: This) {
    // 'this' here is the instance being constructed
    (this as Record<string | symbol, unknown>)[context.name] =
      target.bind(this);
  });
}

class Timer {
  count = 0;

  @log
  increment(): number {
    return ++this.count;
  }

  @bound
  tick(): void {
    this.increment();
  }
}

const t = new Timer();
t.increment(); // [increment] called with: []   [increment] returned: 1
const tick = t.tick; // safely extractable — bound by decorator
tick();
console.log(t.count); // 2
TS5 Field Decorators

TS
// Field/property decorator in ECMAScript style
function required(
  _target: undefined,
  context: ClassFieldDecoratorContext,
) {
  return function (this: object, value: unknown) {
    if (value === undefined || value === null || value === '') {
      throw new Error(`Field "${String(context.name)}" is required`);
    }
    return value;
  };
}

function clamp(min: number, max: number) {
  return function (_target: undefined, _context: ClassFieldDecoratorContext) {
    return function (_this: object, value: unknown): number {
      if (typeof value !== 'number') throw new Error('Expected a number');
      return Math.min(Math.max(value, min), max);
    };
  };
}

class UserProfile {
  @required
  name: string;

  @clamp(0, 150)
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

const p = new UserProfile('Alice', 25);
console.log(p.age); // 25

const p2 = new UserProfile('Bob', 200);
console.log(p2.age); // 150 — clamped

// const p3 = new UserProfile('', 30); // ❌ throws: Field "name" is required
Real-World: NestJS Decorators

NestJS is built entirely on the legacy decorator system. Understanding the patterns it uses helps you build your own frameworks.

TS
import {
  Controller, Get, Post, Body, Param, HttpCode,
  HttpStatus, UseGuards, Injectable,
} from '@nestjs/common';

// ─── Service ──────────────────────────────────────────────────
@Injectable()
export class UserService {
  constructor(
    // @InjectRepository uses parameter decorators + reflect-metadata
    // to wire up the database connection at runtime
    @InjectRepository(User)
    private readonly repo: Repository<User>,
  ) {}

  async findAll(): Promise<User[]> {
    return this.repo.find();
  }

  async findOne(id: string): Promise<User | null> {
    return this.repo.findOneBy({ id });
  }
}

// ─── Controller ───────────────────────────────────────────────
@Controller('users')        // ← class decorator: registers route prefix
@UseGuards(AuthGuard)       // ← class decorator: applies guard to all routes
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()                    // ← method decorator: GET /users
  findAll(): Promise<User[]> {
    return this.userService.findAll();
  }

  @Get(':id')               // ← method decorator: GET /users/:id
  findOne(@Param('id') id: string): Promise<User | null> {
    //      ↑ parameter decorator extracts :id from route params
    return this.userService.findOne(id);
  }

  @Post()                   // ← method decorator: POST /users
  @HttpCode(HttpStatus.CREATED)
  create(@Body() dto: CreateUserDto): Promise<User> {
    //     ↑ parameter decorator parses request body
    return this.userService.create(dto);
  }
}
Real-World: Angular Decorators

TS
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';

@Component({
  selector: 'app-user-card',
  template: `
    <div class="card">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
      <button (click)="onSelect()">Select</button>
    </div>
  `,
  styles: [`
    .card { border: 1px solid #ccc; padding: 1rem; border-radius: 4px; }
  `],
})
export class UserCardComponent implements OnInit {
  @Input() user!: { name: string; email: string }; // receives data from parent
  @Output() selected = new EventEmitter<string>(); // emits to parent

  ngOnInit(): void {
    console.log('UserCard initialised for', this.user.name);
  }

  onSelect(): void {
    this.selected.emit(this.user.email);
  }
}

// Injectable service
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })  // registers in root injector
export class ApiService {
  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users');
  }
}
Building a Custom Validation Decorator System

Here's a complete, production-ready validation decorator system using TS5 ECMAScript decorators — no external dependencies needed.

TS
// validation.ts — TS5 ECMAScript decorators

type Validator = (value: unknown) => string | null;
const VALIDATORS = Symbol('validators');

function addValidator(
  context: ClassFieldDecoratorContext,
  validator: Validator,
) {
  context.addInitializer(function (this: Record<symbol, Validator[]>) {
    const field = String(context.name);
    const existing = (this[VALIDATORS] ??= {}) as Record<string, Validator[]>;
    existing[field] = [...(existing[field] ?? []), validator];
  });
}

export function required(_: undefined, context: ClassFieldDecoratorContext) {
  return (value: unknown) => {
    addValidator(context, (v) =>
      v === undefined || v === null || v === ''
        ? `${String(context.name)} is required`
        : null,
    );
    return value;
  };
}

export function minLength(min: number) {
  return (_: undefined, context: ClassFieldDecoratorContext) => {
    addValidator(context, (v) =>
      typeof v === 'string' && v.length < min
        ? `${String(context.name)} must be at least ${min} characters`
        : null,
    );
    return (value: unknown) => value;
  };
}

export function isEmail(_: undefined, context: ClassFieldDecoratorContext) {
  return (value: unknown) => {
    addValidator(context, (v) =>
      typeof v === 'string' && !/^[^@]+@[^@]+\.[^@]+$/.test(v)
        ? `${String(context.name)} must be a valid email address`
        : null,
    );
    return value;
  };
}

export function validate(instance: object): string[] {
  const validators = (instance as Record<symbol, Record<string, Validator[]>>)[VALIDATORS] ?? {};
  return Object.entries(validators).flatMap(([key, fns]) =>
    fns
      .map(fn => fn((instance as Record<string, unknown>)[key]))
      .filter((e): e is string => e !== null),
  );
}

// Usage
class RegistrationForm {
  @required
  @minLength(2)
  name = '';

  @required
  @isEmail
  email = '';

  @required
  @minLength(8)
  password = '';
}

const form = new RegistrationForm();
form.name = 'A';
form.email = 'not-an-email';
form.password = 'short';

console.log(validate(form));
// ['name must be at least 2 characters',
//  'email must be a valid email address',
//  'password must be at least 8 characters']
Success
This pattern scales to arbitrarily complex validation rules. Libraries like class-validator are built on exactly this foundation — but now you understand how it works under the hood.
Comparison: Legacy vs ECMAScript Decorators

Feature

Legacy (TS4 / experimentalDecorators)

ECMAScript (TS5+)

tsconfig flag needed

experimentalDecorators: true

None (built-in)

Class decorators

Yes

Yes

Method decorators

Yes

Yes

Property/field decorators

Yes (limited)

Yes (initializer support)

Parameter decorators

Yes

Not yet in spec

emitDecoratorMetadata needed

For DI frameworks

No

Standard

TypeScript-specific

TC39 Stage 3 (future JS)

Used by

NestJS, Angular, TypeORM

New libraries, TS5 projects

Summary
  • Decorators are functions applied to classes, methods, properties, or parameters with the @name syntax.

  • The legacy system (experimentalDecorators) is used by NestJS, Angular, and TypeORM.

  • TypeScript 5 supports ECMAScript Stage 3 decorators natively — no flag needed.

  • Class decorators can modify or replace the constructor.

  • Method decorators wrap the function in a new implementation — ideal for logging, caching, and retry logic.

  • Field decorators in TS5 receive an initializer function to transform the initial value.

  • addInitializer in TS5 context objects runs code during instance construction.

  • Parameter decorators are only available in the legacy system; the ES spec does not cover them yet.

  • NestJS and Angular use reflect-metadata with parameter decorators for dependency injection wiring.