Decorators
Decorators admit attaching metadata or behaviour to classes, methods, properties, accessors, and parameters. TypeScript supports two principal decorator forms: the Stage 3 ECMAScript decorators (the modern form, available since TS 5.0 and standardised in upcoming ECMAScript), and the older experimental decorators (under experimentalDecorators: true). The two forms have different semantics; the contemporary recommendation is the Stage 3 form for new code. The conventional uses are framework-driven: dependency injection (NestJS, Angular), API routing (NestJS), validation (class-validator), ORM (TypeORM, MikroORM), serialisation (class-transformer), and observability tooling.
Stage 3 decorators (TS 5.0+)
A decorator is a function applied with the @ syntax:
function logged(value: any, context: ClassMethodDecoratorContext) {
const original = value;
return function (this: any, ...args: any[]) {
console.log(`Calling ${String(context.name)}`);
const result = original.call(this, ...args);
console.log(`Result: ${result}`);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(2, 3);
// "Calling add"
// "Result: 5"
The decorator receives:
- The decorated value — the method, class, accessor, etc.
- A context object — including
kind,name,static,private,addInitializer.
The decorator may return:
- Nothing — the original is preserved.
- A replacement — the original is replaced.
Decorator targets
The Stage 3 decorators apply to several positions:
| Position | Decorator type | Context kind |
|---|---|---|
| Class | ClassDecorator | "class" |
| Method | ClassMethodDecorator | "method" |
| Getter | ClassGetterDecorator | "getter" |
| Setter | ClassSetterDecorator | "setter" |
| Field | ClassFieldDecorator | "field" |
| Auto-accessor | ClassAccessorDecorator | "accessor" |
function classDeco(target: any, context: ClassDecoratorContext) { /* ... */ }
function methodDeco(target: any, context: ClassMethodDecoratorContext) { /* ... */ }
function fieldDeco(target: any, context: ClassFieldDecoratorContext) { /* ... */ }
@classDeco
class Foo {
@fieldDeco
name: string = "";
@methodDeco
method() {}
}
The Stage 3 form does not admit decorators on parameters; for parameter decorators, the legacy experimental decorators are required.
Decorator factories
A decorator factory is a function that returns a decorator — admitting parameterisation:
function logged(prefix: string) {
return function (value: any, context: ClassMethodDecoratorContext) {
const original = value;
return function (this: any, ...args: any[]) {
console.log(`[${prefix}] Calling ${String(context.name)}`);
return original.call(this, ...args);
};
};
}
class Service {
@logged("INFO")
process(data: string): string {
return data.toUpperCase();
}
}
The factory pattern is conventional for any decorator that takes configuration.
Class decorators
A class decorator transforms a class:
function sealed(target: any, context: ClassDecoratorContext) {
Object.seal(target);
Object.seal(target.prototype);
}
@sealed
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
}
A class decorator may return a new class, replacing the original:
function withTimestamp<T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext,
): T {
return class extends target {
timestamp = Date.now();
} as T;
}
@withTimestamp
class Event {
constructor(public name: string) {}
}
const e = new Event("click");
console.log((e as any).timestamp);
Method decorators
function memoize(value: any, context: ClassMethodDecoratorContext) {
const cache = new Map<string, any>();
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, value.call(this, ...args));
}
return cache.get(key);
};
}
class Calculator {
@memoize
expensiveCalc(n: number): number {
console.log(`Computing for ${n}`);
return n * n;
}
}
const c = new Calculator();
c.expensiveCalc(5); // computes
c.expensiveCalc(5); // cached
Field decorators
A field decorator may return an initialiser function:
function defaulted<T>(defaultValue: T) {
return function (value: any, context: ClassFieldDecoratorContext) {
return function (initial: T) {
return initial === undefined ? defaultValue : initial;
};
};
}
class Config {
@defaulted(8080)
port: number;
@defaulted("localhost")
host: string;
}
Accessor decorators
The accessor keyword (TS 4.9+) introduces an auto-accessor — a property with auto-generated getter and setter:
class Counter {
accessor value: number = 0;
// Equivalent to:
// private _value = 0;
// get value() { return this._value; }
// set value(v) { this._value = v; }
}
A decorator on an auto-accessor receives { get, set } and may transform either:
function tracked(value: { get: () => any; set: (v: any) => void }, context: ClassAccessorDecoratorContext) {
return {
get() {
console.log(`Reading ${String(context.name)}`);
return value.get.call(this);
},
set(newValue: any) {
console.log(`Writing ${String(context.name)}: ${newValue}`);
value.set.call(this, newValue);
},
};
}
class Counter {
@tracked
accessor value = 0;
}
const c = new Counter();
c.value = 5; // "Writing value: 5"
c.value; // "Reading value"
Initializers
The addInitializer method on the decorator context admits running code at class construction:
function autoBind(value: any, context: ClassMethodDecoratorContext) {
context.addInitializer(function (this: any) {
this[context.name] = value.bind(this);
});
}
class Handler {
name = "handler";
@autoBind
handle() {
console.log(this.name);
}
}
const h = new Handler();
const fn = h.handle;
fn(); // "handler" (this is bound)
The pattern admits substantial initialisation behaviour at class-construction time.
Legacy experimental decorators
The legacy decorator form (under experimentalDecorators: true) has different semantics; it is the form supported by older frameworks (Angular, NestJS, TypeORM) — most of which are migrating to the Stage 3 form.
// tsconfig.json:
// {
// "compilerOptions": {
// "experimentalDecorators": true,
// "emitDecoratorMetadata": true
// }
// }
function legacyDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey}`);
return original.apply(this, args);
};
}
class Calculator {
@legacyDecorator
add(a: number, b: number): number {
return a + b;
}
}
The legacy form admits parameter decorators:
function inject(token: string) {
return function (target: any, propertyKey: string | undefined, parameterIndex: number) {
// store metadata about the injection
};
}
class Service {
constructor(@inject("logger") private logger: Logger) {}
}
Parameter decorators are not admitted in the Stage 3 form; the conventional defence is a class decorator that processes parameter information from Reflect.metadata (legacy) or constructor argument types directly (Stage 3).
Reflect.metadata (legacy)
Under emitDecoratorMetadata: true, the compiler emits type metadata accessible via Reflect.metadata — admitting runtime introspection of types:
import "reflect-metadata";
function logType(target: any, key: string) {
const t = Reflect.getMetadata("design:type", target, key);
console.log(`${key} type: ${t.name}`);
}
class Foo {
@logType
name: string = "";
@logType
age: number = 0;
}
The reflect-metadata polyfill is required; the mechanism is part of the legacy decorator infrastructure and not in the Stage 3 form. The conventional contemporary alternative is Stage 3 decorators with explicit metadata APIs.
Common patterns
Logging
function logged(value: any, context: ClassMethodDecoratorContext) {
const original = value;
return function (this: any, ...args: any[]) {
console.log(`[${String(context.name)}]`, args);
const result = original.call(this, ...args);
console.log(`[${String(context.name)}] returned`, result);
return result;
};
}
class Service {
@logged
compute(x: number): number {
return x * 2;
}
}
Memoization
function memoize(value: any, context: ClassMethodDecoratorContext) {
const cache = new WeakMap();
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args);
let instanceCache = cache.get(this);
if (!instanceCache) {
instanceCache = new Map();
cache.set(this, instanceCache);
}
if (!instanceCache.has(key)) {
instanceCache.set(key, value.call(this, ...args));
}
return instanceCache.get(key);
};
}
Validation
function range(min: number, max: number) {
return function (
value: { get: () => any; set: (v: any) => void },
context: ClassAccessorDecoratorContext,
) {
return {
get() { return value.get.call(this); },
set(newValue: any) {
if (typeof newValue !== "number" || newValue < min || newValue > max) {
throw new RangeError(`${String(context.name)} must be in [${min}, ${max}]`);
}
value.set.call(this, newValue);
},
};
};
}
class Settings {
@range(0, 100)
accessor volume = 50;
}
const s = new Settings();
s.volume = 75; // OK
// s.volume = 200; // RangeError
Time profiling
function timed(value: any, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
const start = performance.now();
const result = value.call(this, ...args);
const elapsed = performance.now() - start;
console.log(`${String(context.name)} took ${elapsed.toFixed(2)}ms`);
return result;
};
}
Async retry
function retry(attempts: number, delay = 100) {
return function (value: any, context: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await value.apply(this, args);
} catch (e) {
lastError = e;
if (i < attempts - 1) {
await new Promise(r => setTimeout(r, delay * (i + 1)));
}
}
}
throw lastError;
};
};
}
class ApiClient {
@retry(3, 1000)
async fetchData(url: string): Promise<unknown> {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
}
Framework patterns
NestJS uses decorators substantially:
// NestJS example (uses experimental decorators):
@Controller("users")
class UserController {
constructor(@Inject(UserService) private service: UserService) {}
@Get()
findAll(): Promise<User[]> {
return this.service.findAll();
}
@Post()
create(@Body() data: CreateUserDto): Promise<User> {
return this.service.create(data);
}
}
The pattern admits substantial declarative configuration; the framework processes the decorators at startup to register routes, dependencies, and middleware.
A note on the migration from legacy
For codebases using legacy experimental decorators:
- Library compatibility — most major frameworks (Angular, NestJS, TypeORM) still use experimental decorators as of TS 5.7; migration plans exist.
- Stage 3 in new code — for new projects without framework constraints, prefer Stage 3.
- Parameter decorators — only legacy form admits them; Stage 3 does not.
- Reflect metadata — legacy form; Stage 3 has no built-in equivalent.
The conventional contemporary advice depends on the framework: follow the framework’s recommendation. For framework-free code, Stage 3 is the conventional choice.
A note on the conventional discipline
The contemporary TypeScript decorator advice:
- Use Stage 3 decorators for new code without framework constraints.
- Use legacy experimental decorators when the framework requires them.
- Use decorator factories for parameterisation.
- Be cautious with metaprogramming — decorators introduce indirection that complicates debugging.
- Document decorator behaviour — they are conventionally non-obvious.
- Prefer plain functions for simple wrapping; use decorators when the syntax sugar is genuinely valuable.
- Wait for ecosystem migration — many frameworks are still on the legacy form.
The combination — Stage 3 decorators on classes, methods, fields, accessors, with factories for parameterisation, and the legacy form for parameter decorators and framework integration — is the substance of TypeScript’s decorator surface. The discipline trades indirection for declarative power; the mechanism is most valuable in framework-driven contexts where the additional structure is amortised across substantial code.