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:
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true // needed for reflect-metadata (DI frameworks)
}
}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).
// 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// 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.
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: 7Legacy 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.
// 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.
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:
Parameter decorators, then method/accessor/property decorators for each instance member (bottom-to-top)
Parameter decorators, then method/accessor/property decorators for each static member (bottom-to-top)
Parameter decorators for the constructor
Class decorators (bottom-to-top)
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: C1TypeScript 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
experimentalDecoratorsflag needed - Decorators receive a context object instead of raw prototype/descriptor arguments
addInitializerallows running code at construction time- No support for parameter decorators yet (coming later in the spec)
// 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
// 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); // 2TS5 Field Decorators
// 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 requiredReal-World: NestJS Decorators
NestJS is built entirely on the legacy decorator system. Understanding the patterns it uses helps you build your own frameworks.
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
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.
// 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']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.