cheat sheet
Decorators
TypeScript decorators annotate and transform classes and class members. Covers the TC39 stage-3 standard decorators (TS 5.0+), legacy experimentalDecorators, decorator factories, method/class/field decorators, and reflect-metadata for NestJS and Angular patterns.
Decorators
What it is
Decorators are a syntactic feature that annotate and optionally transform classes and class members using @Expression syntax. TypeScript 5.0 introduced standard decorators matching the TC39 stage-3 proposal, replacing the older experimentalDecorators API used by Angular, NestJS, and TypeORM. The two systems are distinct, incompatible, and cannot be mixed in the same file.
Legacy decorators (experimentalDecorators)
Enable in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Class decorator (legacy)
Receives the constructor and can return a new constructor to replace the class:
function Sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@Sealed
class BugReport {
type = "report";
constructor(public title: string) {}
}
Method decorator (legacy)
Receives the target object, the method name, and the property descriptor:
function Log(
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const original = descriptor.value as (...args: unknown[]) => unknown;
descriptor.value = function (...args: unknown[]) {
console.log(`Calling ${propertyKey} with`, args);
const result = original.apply(this, args);
console.log(`${propertyKey} returned`, result);
return result;
};
return descriptor;
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
Output:
Calling add with [2, 3]
add returned 5
Property decorator (legacy)
Receives the target prototype and the property name. Cannot access the value directly.
function Validate(min: number, max: number) {
return function (target: object, propertyKey: string): void {
let value: number;
Object.defineProperty(target, propertyKey, {
get() { return value; },
set(newVal: number) {
if (newVal < min || newVal > max) {
throw new RangeError(
`${propertyKey} must be between ${min} and ${max}`
);
}
value = newVal;
},
enumerable: true,
configurable: true,
});
};
}
class Temperature {
@Validate(-273.15, 1000)
celsius!: number;
}
const t = new Temperature();
t.celsius = 25; // OK
t.celsius = -300; // RangeError: celsius must be between -273.15 and 1000
Accessor decorator (legacy)
Applied to a getter or setter; receives the descriptor for both:
function Configurable(writable: boolean) {
return function (
_target: object,
_key: string,
descriptor: PropertyDescriptor
) {
descriptor.configurable = writable;
return descriptor;
};
}
class Circle {
private _radius: number;
constructor(radius: number) { this._radius = radius; }
@Configurable(false)
get radius(): number { return this._radius; }
}
Parameter decorator (legacy)
Receives the target, the method name, and the parameter index. Primarily used with reflect-metadata.
function Required(target: object, propertyKey: string, parameterIndex: number) {
const existingRequired: number[] =
Reflect.getOwnMetadata("required", target, propertyKey) ?? [];
existingRequired.push(parameterIndex);
Reflect.defineMetadata("required", existingRequired, target, propertyKey);
}
class UserService {
createUser(@Required name: string, @Required email: string): void {
console.log(`Creating ${name} <${email}>`);
}
}
TS 5.0+ standard decorators
Standard decorators require no tsconfig.json flag — they are enabled by default in TypeScript 5.0+. Do not set experimentalDecorators: true when using the new API.
Class decorator (standard)
Receives the class and an optional context object. Returns a replacement class or undefined:
type ClassDecorator<T extends abstract new (...args: any) => any> =
(target: T, context: ClassDecoratorContext) => T | void;
function Singleton<T extends new (...args: any[]) => any>(
target: T,
_context: ClassDecoratorContext
): T {
let instance: InstanceType<T> | undefined;
return class extends target {
constructor(...args: any[]) {
super(...args);
if (instance) return instance;
instance = this as InstanceType<T>;
}
} as T;
}
@Singleton
class Database {
connection = Math.random();
}
const a = new Database();
const b = new Database();
console.log(a === b); // true
console.log(a.connection === b.connection); // true
Output:
true
true
Method decorator (standard)
Receives the original method and a context object. Returns a replacement function or undefined:
function Memoize<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const cache = new Map<string, Return>();
return function (this: This, ...args: Args): Return {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = target.apply(this, args);
cache.set(key, result);
return result;
};
}
class MathUtils {
@Memoize
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
const m = new MathUtils();
console.log(m.fibonacci(40)); // computed once, cached after
console.log(m.fibonacci(40)); // returned from cache
Getter/setter decorator (standard)
Applied to a getter (or setter) method; receives the original accessor function and a ClassGetterDecoratorContext (or setter equivalent) and returns a replacement accessor. Use it to add lazy-computation, logging, or caching logic to a property without touching the class body.
function Lazy<T>(
target: () => T,
context: ClassGetterDecoratorContext
): () => T {
return function (this: object): T {
const value = target.call(this);
Object.defineProperty(this, context.name, {
value,
writable: false,
enumerable: true,
configurable: false,
});
return value;
};
}
class Config {
@Lazy
get expensiveValue(): string {
console.log("Computing...");
return "result";
}
}
const cfg = new Config();
console.log(cfg.expensiveValue); // "Computing..." then "result"
console.log(cfg.expensiveValue); // "result" (from cached property)
Field decorator (standard)
Field decorators receive undefined as the target (since the field doesn't have a value at decoration time). They return an initializer function:
function Required<T>(
_target: undefined,
context: ClassFieldDecoratorContext
): (value: T) => T {
return function (value: T): T {
if (value === undefined || value === null) {
throw new Error(`Field '${String(context.name)}' is required`);
}
return value;
};
}
class User {
@Required
name: string = "Alice";
@Required
email: string = "alice@example.com";
}
Auto-accessor decorator (standard)
The accessor keyword creates a private backing field + getter/setter pair that decorators can intercept:
function Range(min: number, max: number) {
return function <T>(
target: ClassAccessorDecoratorTarget<T, number>,
_context: ClassAccessorDecoratorContext<T, number>
): ClassAccessorDecoratorResult<T, number> {
return {
set(value: number) {
if (value < min || value > max) {
throw new RangeError(`Value must be between ${min} and ${max}`);
}
target.set.call(this, value);
},
};
};
}
class Gauge {
@Range(0, 100)
accessor level = 50;
}
const g = new Gauge();
g.level = 75; // OK
g.level = 120; // RangeError: Value must be between 0 and 100
Decorator factories
A decorator factory is a function that returns a decorator. This allows passing arguments to decorators:
// Factory: returns a method decorator
function Throttle(delay: number) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
_context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return | undefined {
let lastCall = 0;
return function (this: This, ...args: Args): Return | undefined {
const now = Date.now();
if (now - lastCall < delay) return;
lastCall = now;
return target.apply(this, args);
};
};
}
class SearchComponent {
@Throttle(300)
search(query: string): void {
console.log(`Searching for: ${query}`);
}
}
Route decorator factory (NestJS-style pattern):
const routeRegistry = new Map<Function, { method: string; path: string }[]>();
function Route(method: string, path: string) {
return function (
target: Function,
context: ClassMethodDecoratorContext
): void {
const routes = routeRegistry.get(context.metadata) ?? [];
routes.push({ method, path });
routeRegistry.set(context.metadata, routes);
};
}
class UserController {
@Route("GET", "/users")
listUsers() { /* ... */ }
@Route("POST", "/users")
createUser() { /* ... */ }
}
Reflect metadata (legacy NestJS / Angular pattern)
reflect-metadata is a polyfill for the metadata proposal. It is used with emitDecoratorMetadata: true to automatically store TypeScript type information at runtime. This powers NestJS dependency injection.
npm install reflect-metadata
Output: (none — exits 0 on success)
import "reflect-metadata"; // must be imported once at app entry point
function Injectable() {
return function (target: Function): void {
// emitDecoratorMetadata stores parameter types automatically
const paramTypes: Function[] =
Reflect.getMetadata("design:paramtypes", target) ?? [];
console.log("Injectable:", target.name, "with deps:", paramTypes.map((t) => t.name));
};
}
@Injectable()
class EmailService {
send(to: string, body: string): void { /* ... */ }
}
@Injectable()
class UserService {
constructor(private emailService: EmailService) {}
}
Output:
Injectable: EmailService with deps: []
Injectable: UserService with deps: ["EmailService"]
emitDecoratorMetadataonly works withexperimentalDecorators: true. The standard TS 5.0+ decorator API does not supportemitDecoratorMetadata. NestJS and Angular still use the legacy decorator API. Check your framework's documentation before mixing the two.
When to use decorators
Good use cases:
- Framework integration (NestJS
@Injectable,@Controller; Angular@Component; TypeORM@Entity,@Column) - Cross-cutting concerns: logging, memoization, throttling, retry logic
- Metadata annotation (API documentation, route registration)
- Property validation in class-oriented code
Avoid decorators for:
- Plain utility functions — generics and composition are simpler and tree-shakeable
- Modules you want to be framework-agnostic
- Functional programming patterns — decorators are tightly coupled to classes
Legacy vs standard decorator comparison
| Feature | experimentalDecorators | TS 5.0+ Standard |
|---|---|---|
| tsconfig flag required | experimentalDecorators: true | None |
| Spec status | Non-standard (dead proposal) | TC39 Stage 3 |
| Class decorator receives | Constructor | Constructor + context |
| Method decorator receives | Descriptor | Function + context |
| Field decorator | Can read/write via defineProperty | Returns initializer function |
accessor keyword | Not supported | Supported |
emitDecoratorMetadata | Works | Not supported |
| NestJS / Angular | Uses this | Uses legacy still (2026) |
| New projects | Avoid | Preferred |
Decorator execution order
Decorators evaluate and apply in a specific order. Understanding the sequence helps debug subtle composition bugs — especially when multiple decorators stack on the same target.
Multiple decorators on the same target
Decorator factories run top-to-bottom (the way you read them), but the returned decorator functions apply bottom-to-top (closest to the declaration first). This is the same as mathematical function composition: @A @B method is A(B(method)).
function logFactory(name: string) {
console.log(`evaluate ${name}`);
return function (target: any, context: ClassMethodDecoratorContext) {
console.log(`apply ${name}`);
return target;
};
}
class Demo {
@logFactory("outer")
@logFactory("middle")
@logFactory("inner")
run() {}
}
Output:
evaluate outer
evaluate middle
evaluate inner
apply inner
apply middle
apply outer
The factories evaluate top-down because that's the order JavaScript reads the source. The decorator functions they return apply bottom-up because each one wraps the previous return value.
Order across decorator targets
When multiple kinds of decorators apply in the same class, the order is:
- Parameter decorators (on each method, then on the constructor)
- Method, accessor, and field decorators (in source order)
- Class decorators (last)
function trace(label: string): any {
console.log(`define ${label}`);
return function () { console.log(`apply ${label}`); };
}
@trace("class")
class Order {
@trace("field-a")
a = 1;
@trace("method")
run(@trace("param") x: number) {}
@trace("field-b")
b = 2;
}
Output (legacy decorators):
define class
define field-a
define method
define param
define field-b
apply param
apply method
apply field-a
apply field-b
apply class
The standard TS 5.0+ semantics differ slightly — fields and methods apply in source order and the class decorator runs last, but the parameter-decorator step doesn't exist (parameter decorators aren't part of the TC39 standard yet — see below). Always test your assumption with a small script.
Decorator metadata (standard TS 5.2+)
TC39 Stage 3 decorators expose a context.metadata slot for attaching arbitrary data to the decorated class. The metadata is inherited by subclasses and accessible via Symbol.metadata. This replaces the legacy Reflect.metadata pattern with a standard mechanism — no reflect-metadata polyfill needed in TS 5.2+ projects targeting environments with Symbol.metadata support.
const FIELDS = Symbol("fields");
function track(_target: undefined, context: ClassFieldDecoratorContext): void {
context.metadata[FIELDS] ??= [];
(context.metadata[FIELDS] as string[]).push(String(context.name));
}
class User {
@track
name = "Alice Dev";
@track
email = "alice@example.com";
}
const meta = User[Symbol.metadata];
console.log(meta?.[FIELDS]);
Output:
["name", "email"]
The metadata survives subclassing — class Admin extends User { @track role = "admin"; } would have ["name", "email", "role"] in its metadata.
Enable the runtime polyfill in older environments:
// At app entry point
(Symbol as any).metadata ??= Symbol.for("Symbol.metadata");
node --experimental-vm-modules --no-warnings src/index.js
Output:
["name", "email"]
The standard decorator API does not expose parameter types automatically the way emitDecoratorMetadata did. You must declare types explicitly in the metadata if a framework needs them — or stick with the legacy API.
reflect-metadata vs Symbol.metadata
Both mechanisms achieve "store data on a class at decoration time", but the surface and semantics differ.
| Concern | reflect-metadata (legacy) | Symbol.metadata (TS 5.2+ standard) |
|---|---|---|
| Source | npm package polyfill | Built into TC39 decorators |
| tsconfig | experimentalDecorators: true, emitDecoratorMetadata: true | No flags |
| API | Reflect.defineMetadata, Reflect.getMetadata, etc. | context.metadata (object) |
| Auto-stores types | Yes (design:type, design:paramtypes, design:returntype) | No |
| Inherited by subclass | Yes | Yes |
| Per-key namespacing | Yes (reflect-metadata uses string keys) | Manual (use Symbol keys) |
| Runtime support | Universal via polyfill | TS 5.2+ syntax; runtime support varies |
NestJS, Angular, TypeORM, and class-validator all use reflect-metadata and the legacy decorator API. They will not move to standard decorators until their respective frameworks ship a major version that adopts the new API — currently expected for NestJS 11, Angular 19+.
For a new project that does not depend on those frameworks, prefer Symbol.metadata:
npm install -D typescript@5.4
Output:
added 1 package in 0.3s
// No reflect-metadata import needed
function table(name: string) {
return function (_target: Function, context: ClassDecoratorContext) {
context.metadata.tableName = name;
};
}
@table("users")
class User { /* ... */ }
console.log(User[Symbol.metadata]?.tableName);
Output:
users
Common framework patterns
The four big users of decorators in the JavaScript ecosystem still target experimentalDecorators. Knowing the standard shapes helps when integrating with them or when migrating away.
NestJS — dependency injection and HTTP routing
NestJS uses decorators for almost every API surface: classes (@Controller, @Injectable, @Module), methods (@Get, @Post), parameters (@Body, @Query, @Param), and properties (@Inject).
import "reflect-metadata";
import { Controller, Get, Post, Body, Param, Injectable } from "@nestjs/common";
@Injectable()
class UserService {
findOne(id: string) { return { id, name: "Alice Dev" }; }
create(data: { name: string }) { return { id: "u_1", ...data }; }
}
@Controller("users")
class UserController {
constructor(private readonly userService: UserService) {}
@Get(":id")
findOne(@Param("id") id: string) {
return this.userService.findOne(id);
}
@Post()
create(@Body() body: { name: string }) {
return this.userService.create(body);
}
}
The constructor injection (private readonly userService: UserService) works because emitDecoratorMetadata: true stores design:paramtypes at runtime. NestJS reads that metadata and resolves dependencies from its injection container.
Required tsconfig:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2021",
"module": "CommonJS"
}
}
npx nest start
Output:
[Nest] 12345 - 14:00:00 LOG [NestFactory] Starting Nest application...
[Nest] 12345 - 14:00:00 LOG [InstanceLoader] AppModule dependencies initialized
[Nest] 12345 - 14:00:00 LOG [RoutesResolver] UserController {/users}
[Nest] 12345 - 14:00:00 LOG [RouterExplorer] Mapped {/users/:id, GET}
[Nest] 12345 - 14:00:00 LOG [RouterExplorer] Mapped {/users, POST}
[Nest] 12345 - 14:00:00 LOG Nest application successfully started
TypeORM — entity definitions
TypeORM uses decorators to map class shape to a database table.
import "reflect-metadata";
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from "typeorm";
@Entity("users")
class UserEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "varchar", length: 100 })
name!: string;
@Column({ unique: true })
email!: string;
@Column({ default: "member" })
role!: "admin" | "member" | "viewer";
}
@PrimaryGeneratedColumn, @Column, and @OneToMany all attach metadata via reflect-metadata. TypeORM's connection logic reads it to generate SQL.
Angular — component metadata
Angular's @Component and @Directive annotate classes with template, selector, and provider info. Angular's compiler reads the metadata at AOT (ahead-of-time) compile time, so the runtime cost is minimal.
import { Component, Input, Output, EventEmitter } from "@angular/core";
@Component({
selector: "app-user-card",
template: `
<div class="card">
<h2>{{ user.name }}</h2>
<button (click)="onSelect.emit(user.id)">Select</button>
</div>
`,
})
class UserCardComponent {
@Input() user!: { id: string; name: string };
@Output() onSelect = new EventEmitter<string>();
}
class-validator — runtime validation
class-validator pairs with class-transformer to add property-level validation rules via decorators.
import "reflect-metadata";
import { IsEmail, IsString, MinLength, Max, validate } from "class-validator";
class CreateUserDto {
@IsString()
@MinLength(2)
name!: string;
@IsEmail()
email!: string;
@Max(120)
age!: number;
}
const dto = new CreateUserDto();
dto.name = "A";
dto.email = "not-an-email";
dto.age = 200;
const errors = await validate(dto);
console.log(errors);
Output:
[
ValidationError { property: "name", constraints: { minLength: "name must be longer than or equal to 2 characters" } },
ValidationError { property: "email", constraints: { isEmail: "email must be an email" } },
ValidationError { property: "age", constraints: { max: "age must not be greater than 120" } }
]
These decorators rely on reflect-metadata to store the validation rules; the validate() call reads them at runtime.
Migrating from legacy to standard decorators
The two systems are incompatible — a single file uses one or the other. Migration is per-file and per-decorator. The framework constraints typically force the choice: if you use NestJS or TypeORM, stay on legacy; otherwise prefer standard.
Step-by-step migration
- Remove the tsconfig flags
{
"compilerOptions": {
// Remove these:
// "experimentalDecorators": true,
// "emitDecoratorMetadata": true,
// The standard API needs nothing:
}
}
- Rewrite each decorator's signature
// Legacy
function log(target: object, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: unknown[]) {
console.log(`call ${propertyKey}`, args);
return original.apply(this, args);
};
return descriptor;
}
// Standard
function log<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
return function (this: This, ...args: Args): Return {
console.log(`call ${String(context.name)}`, args);
return target.apply(this, args);
};
}
- Replace
Reflect.metadatawithcontext.metadata
// Legacy
import "reflect-metadata";
function tag(name: string) {
return function (target: Function) {
Reflect.defineMetadata("tag", name, target);
};
}
// Standard
function tag(name: string) {
return function (target: Function, context: ClassDecoratorContext) {
context.metadata.tag = name;
};
}
- Verify with a minimal test
npx tsc --noEmit
node dist/test.js
Output:
tag: my-tag
What you lose, what you gain
| Concern | Legacy → Standard |
|---|---|
| Parameter decorators | Lost — TC39 hasn't standardised them yet. Workaround: use accessor decorators on a private field, or fall back to a factory pattern. |
emitDecoratorMetadata (auto type info) | Lost. You must declare types in metadata yourself. |
| Compatibility with NestJS/Angular | Lost. Their classes still use legacy decorators. |
| Composability and types | Gained — standard decorators have well-defined generic contexts and don't rely on global side effects. |
| Tree-shaking | Gained — pure decorators (no Reflect.metadata) can be tree-shaken. |
| Standard spec | Gained — your code is portable to any TC39-compliant runtime. |
Decorator-friendly patterns without decorators
For projects that want the idea of decorators without the syntax complexity, several functional patterns achieve the same goals.
Higher-order functions
The cleanest substitute. withLog(method) is the functional cousin of @log method().
function withLog<T extends (...args: any[]) => any>(name: string, fn: T): T {
return ((...args: any[]) => {
console.log(`call ${name}`, args);
const result = fn(...args);
console.log(`return ${name}`, result);
return result;
}) as T;
}
class Calculator {
add = withLog("add", (a: number, b: number) => a + b);
}
new Calculator().add(2, 3);
Output:
call add [2, 3]
return add 5
Mixins for class augmentation
Where a class decorator would replace a constructor, a mixin function wraps a base class.
type Constructor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date();
};
}
class User {
constructor(public name: string) {}
}
class TimestampedUser extends Timestamped(User) {}
const u = new TimestampedUser("Alice Dev");
console.log(u.name, u.createdAt.toISOString());
Output:
Alice Dev 2026-05-25T14:00:00.000Z
Builder pattern for metadata
Where field decorators store metadata, a builder collects it explicitly.
type Schema = { fields: Array<{ name: string; type: string; required: boolean }> };
class SchemaBuilder {
private schema: Schema = { fields: [] };
field(name: string, type: string, required = false) {
this.schema.fields.push({ name, type, required });
return this;
}
build() { return this.schema; }
}
const userSchema = new SchemaBuilder()
.field("name", "string", true)
.field("email", "string", true)
.field("age", "number", false)
.build();
console.log(userSchema);
Output:
{
fields: [
{ name: "name", type: "string", required: true },
{ name: "email", type: "string", required: true },
{ name: "age", type: "number", required: false }
]
}
These patterns are tree-shakeable, framework-agnostic, and TypeScript-friendly without any decorator setup. Reach for them when you don't need a framework's runtime introspection.
Common pitfalls
- Mixing legacy and standard decorators in one file — TS errors out. The compiler honours
experimentalDecoratorsper-project, not per-file. Migrate file-by-file by separating into different projects withreferences, or commit fully to one API. - Forgetting
import "reflect-metadata"at the entry point — NestJS, TypeORM, and class-validator silently lose all their type information. Always import it once, before any decorated class is loaded. emitDecoratorMetadatawithoutexperimentalDecorators— the option is silently ignored. Always enable both together.- Field decorator returning
undefinedwhen an initializer is expected — the standard API's field decorators must return an initializer function orundefined. Returning a non-function value crashes the class definition. - Parameter decorators in standard API — they don't exist in TC39 Stage 3. Code that relies on
@Inject()on constructor parameters must keep legacy decorators. - Class decorator returning a wrong constructor signature — the returned class must extend the original with
extends targetand forward...argstosuper. Otherwise instances lose properties. - Decorators on private fields (
#field) — neither API supports them. Useprivate field(TS-only visibility, not runtime) for decorated fields. - Stale
.tsbuildinfoafter flipping decorator settings — the cache doesn't knowexperimentalDecoratorschanged. Runtsc --build --forceafter toggling. useDefineForClassFieldsand legacy decorators — whenuseDefineForClassFields: true(default in TS 5.x withtarget: ES2022+), legacy property decorators that useObject.definePropertymay conflict with ES2022 class-field semantics. SetuseDefineForClassFields: falsefor legacy-decorator codebases.- Standard decorators with old
target— TS emits decorator code that assumes theSymbol.metadataslot exists. Targeting ES5/ES2015 without a polyfill leaves the decorators broken at runtime.
Real-world recipes
Method timing decorator (standard)
A reusable @timed decorator that logs each method's execution time. Common in performance debugging.
function timed<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
const start = performance.now();
try {
return target.apply(this, args);
} finally {
const ms = (performance.now() - start).toFixed(2);
console.log(`[${name}] ${ms}ms`);
}
};
}
class Reports {
@timed
generate() {
let sum = 0;
for (let i = 0; i < 1e7; i++) sum += i;
return sum;
}
}
new Reports().generate();
Output:
[generate] 14.32ms
Retry decorator with exponential backoff
A common cross-cutting concern — automatic retry on failure with backoff. Works as a method decorator factory.
function retry(maxAttempts = 3, baseDelayMs = 100) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext
) {
const name = String(context.name);
return async function (this: This, ...args: Args): Promise<Return> {
let lastErr: unknown;
for (let i = 0; i < maxAttempts; i++) {
try {
return await target.apply(this, args);
} catch (e) {
lastErr = e;
const wait = baseDelayMs * 2 ** i;
console.warn(`[${name}] attempt ${i + 1} failed; retrying in ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
};
};
}
class ApiClient {
@retry(3, 50)
async fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
node dist/index.js
Output:
[fetchUser] attempt 1 failed; retrying in 50ms
[fetchUser] attempt 2 failed; retrying in 100ms
{ id: "u_1", name: "Alice Dev" }
Memoize decorator with cache eviction
The memoize pattern from earlier in the article extended with size limits.
function memoize(maxSize = 100) {
return function <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext
) {
const cache = new Map<string, Return>();
return function (this: This, ...args: Args): Return {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached !== undefined) {
cache.delete(key);
cache.set(key, cached);
return cached;
}
const result = target.apply(this, args);
cache.set(key, result);
if (cache.size > maxSize) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
return result;
};
};
}
class Math2 {
@memoize(50)
fib(n: number): number {
return n <= 1 ? n : this.fib(n - 1) + this.fib(n - 2);
}
}
LRU eviction (delete + re-set on hit) keeps the most recently used 50 entries.
Validation decorator with Symbol.metadata
Author a @min/@max pair that records constraints in metadata, then a validate() function that reads them.
const CONSTRAINTS = Symbol("constraints");
type Constraint = { name: string; min?: number; max?: number };
function min(value: number) {
return function (_target: undefined, context: ClassFieldDecoratorContext): void {
const list = (context.metadata[CONSTRAINTS] ??= []) as Constraint[];
list.push({ name: String(context.name), min: value });
};
}
function max(value: number) {
return function (_target: undefined, context: ClassFieldDecoratorContext): void {
const list = (context.metadata[CONSTRAINTS] ??= []) as Constraint[];
list.push({ name: String(context.name), max: value });
};
}
function validate(obj: object): string[] {
const ctor = obj.constructor as any;
const meta = ctor[Symbol.metadata];
const list = (meta?.[CONSTRAINTS] ?? []) as Constraint[];
const errors: string[] = [];
for (const c of list) {
const val = (obj as any)[c.name];
if (c.min !== undefined && val < c.min) errors.push(`${c.name} < ${c.min}`);
if (c.max !== undefined && val > c.max) errors.push(`${c.name} > ${c.max}`);
}
return errors;
}
class Product {
@min(0)
price = 9.99;
@min(0) @max(1_000_000)
stock = 50;
}
console.log(validate(new Product()));
Output:
[]
const broken = new Product();
broken.price = -1;
broken.stock = 5_000_000;
console.log(validate(broken));
Output:
["price < 0", "stock > 1000000"]
This is exactly the pattern class-validator uses, except built on Symbol.metadata instead of reflect-metadata — fewer dependencies, tree-shakeable, no runtime polyfill.
Auto-bind methods to instances
A class-level decorator that binds every method to the instance, so passing a method reference to a callback works without losing this.
function autobind<T extends new (...args: any[]) => any>(
target: T,
_context: ClassDecoratorContext
): T {
return class extends target {
constructor(...args: any[]) {
super(...args);
const proto = target.prototype;
for (const name of Object.getOwnPropertyNames(proto)) {
if (name === "constructor") continue;
const value = proto[name];
if (typeof value === "function") {
(this as any)[name] = value.bind(this);
}
}
}
};
}
@autobind
class Handler {
constructor(public label: string) {}
greet() { return `Hello from ${this.label}`; }
}
const h = new Handler("server");
const ref = h.greet; // detached reference
console.log(ref()); // still works
Output:
Hello from server
Without @autobind, ref() would crash with Cannot read property 'label' of undefined.
Route registration via class metadata
A miniature framework: collect @Route declarations into a global registry, then assemble an Express-style router.
type Route = { method: "GET" | "POST"; path: string; handler: Function };
const ROUTES = Symbol("routes");
function Get(path: string) {
return function (target: Function, context: ClassMethodDecoratorContext): void {
const list = (context.metadata[ROUTES] ??= []) as Route[];
list.push({ method: "GET", path, handler: target });
};
}
function Post(path: string) {
return function (target: Function, context: ClassMethodDecoratorContext): void {
const list = (context.metadata[ROUTES] ??= []) as Route[];
list.push({ method: "POST", path, handler: target });
};
}
class UserController {
@Get("/users")
list() { return ["Alice Dev", "Bob"]; }
@Get("/users/:id")
show(id: string) { return { id, name: "Alice Dev" }; }
@Post("/users")
create(body: { name: string }) { return { id: "u_2", ...body }; }
}
const meta = (UserController as any)[Symbol.metadata];
console.log(meta?.[ROUTES]?.map((r: Route) => `${r.method} ${r.path}`));
Output:
["GET /users", "GET /users/:id", "POST /users"]
This is the pattern NestJS uses, modernised to the standard decorator API. The framework reads the registry at startup and wires each route to the appropriate handler.
When NOT to use decorators
Decorators add complexity. For each of the following, prefer a simpler pattern:
- A one-off cross-cutting concern in a single function — use a higher-order function (
withRetry(fn)not@retry). - Type-only annotations — TypeScript's type system handles this without runtime cost. Use type aliases.
- Framework-agnostic utility code — decorators couple you to class syntax. Pure functions compose better.
- Tree-shaking matters — decorators that touch shared state (like
Reflect.metadata) defeat tree-shaking. - You're not on TS 5.0+ — the legacy API has serious composition limitations. If you can't upgrade, accept the limitations.
The right time to reach for decorators: when a framework requires them, when metadata genuinely needs to be discoverable at runtime, or when the resulting code is dramatically clearer than the alternatives.