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:

json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Class decorator (legacy)

Receives the constructor and can return a new constructor to replace the class:

typescript
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:

typescript
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:

text
Calling add with [2, 3]
add returned 5

Property decorator (legacy)

Receives the target prototype and the property name. Cannot access the value directly.

typescript
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:

typescript
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.

typescript
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:

typescript
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:

text
true
true

Method decorator (standard)

Receives the original method and a context object. Returns a replacement function or undefined:

typescript
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.

typescript
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:

typescript
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:

typescript
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:

typescript
// 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):

typescript
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.

bash
npm install reflect-metadata

Output: (none — exits 0 on success)

typescript
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:

text
Injectable: EmailService with deps: []
Injectable: UserService with deps: ["EmailService"]

emitDecoratorMetadata only works with experimentalDecorators: true. The standard TS 5.0+ decorator API does not support emitDecoratorMetadata. 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

FeatureexperimentalDecoratorsTS 5.0+ Standard
tsconfig flag requiredexperimentalDecorators: trueNone
Spec statusNon-standard (dead proposal)TC39 Stage 3
Class decorator receivesConstructorConstructor + context
Method decorator receivesDescriptorFunction + context
Field decoratorCan read/write via definePropertyReturns initializer function
accessor keywordNot supportedSupported
emitDecoratorMetadataWorksNot supported
NestJS / AngularUses thisUses legacy still (2026)
New projectsAvoidPreferred

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)).

typescript
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:

text
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:

  1. Parameter decorators (on each method, then on the constructor)
  2. Method, accessor, and field decorators (in source order)
  3. Class decorators (last)
typescript
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):

text
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.

typescript
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:

text
["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:

typescript
// At app entry point
(Symbol as any).metadata ??= Symbol.for("Symbol.metadata");
bash
node --experimental-vm-modules --no-warnings src/index.js

Output:

text
["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.

Concernreflect-metadata (legacy)Symbol.metadata (TS 5.2+ standard)
Sourcenpm package polyfillBuilt into TC39 decorators
tsconfigexperimentalDecorators: true, emitDecoratorMetadata: trueNo flags
APIReflect.defineMetadata, Reflect.getMetadata, etc.context.metadata (object)
Auto-stores typesYes (design:type, design:paramtypes, design:returntype)No
Inherited by subclassYesYes
Per-key namespacingYes (reflect-metadata uses string keys)Manual (use Symbol keys)
Runtime supportUniversal via polyfillTS 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:

bash
npm install -D typescript@5.4

Output:

text
added 1 package in 0.3s
typescript
// 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:

text
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).

typescript
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:

json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2021",
    "module": "CommonJS"
  }
}
bash
npx nest start

Output:

text
[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.

typescript
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.

typescript
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.

typescript
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:

text
[
  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

  1. Remove the tsconfig flags
json
{
  "compilerOptions": {
    // Remove these:
    // "experimentalDecorators": true,
    // "emitDecoratorMetadata": true,

    // The standard API needs nothing:
  }
}
  1. Rewrite each decorator's signature
typescript
// 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);
  };
}
  1. Replace Reflect.metadata with context.metadata
typescript
// 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;
  };
}
  1. Verify with a minimal test
bash
npx tsc --noEmit
node dist/test.js

Output:

text
tag: my-tag

What you lose, what you gain

ConcernLegacy → Standard
Parameter decoratorsLost — 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/AngularLost. Their classes still use legacy decorators.
Composability and typesGained — standard decorators have well-defined generic contexts and don't rely on global side effects.
Tree-shakingGained — pure decorators (no Reflect.metadata) can be tree-shaken.
Standard specGained — 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().

typescript
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:

text
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.

typescript
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:

text
Alice Dev 2026-05-25T14:00:00.000Z

Builder pattern for metadata

Where field decorators store metadata, a builder collects it explicitly.

typescript
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:

text
{
  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

  1. Mixing legacy and standard decorators in one file — TS errors out. The compiler honours experimentalDecorators per-project, not per-file. Migrate file-by-file by separating into different projects with references, or commit fully to one API.
  2. 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.
  3. emitDecoratorMetadata without experimentalDecorators — the option is silently ignored. Always enable both together.
  4. Field decorator returning undefined when an initializer is expected — the standard API's field decorators must return an initializer function or undefined. Returning a non-function value crashes the class definition.
  5. 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.
  6. Class decorator returning a wrong constructor signature — the returned class must extend the original with extends target and forward ...args to super. Otherwise instances lose properties.
  7. Decorators on private fields (#field) — neither API supports them. Use private field (TS-only visibility, not runtime) for decorated fields.
  8. Stale .tsbuildinfo after flipping decorator settings — the cache doesn't know experimentalDecorators changed. Run tsc --build --force after toggling.
  9. useDefineForClassFields and legacy decorators — when useDefineForClassFields: true (default in TS 5.x with target: ES2022+), legacy property decorators that use Object.defineProperty may conflict with ES2022 class-field semantics. Set useDefineForClassFields: false for legacy-decorator codebases.
  10. Standard decorators with old target — TS emits decorator code that assumes the Symbol.metadata slot 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.

typescript
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:

text
[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.

typescript
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();
  }
}
bash
node dist/index.js

Output:

text
[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.

typescript
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.

typescript
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:

text
[]
typescript
const broken = new Product();
broken.price = -1;
broken.stock = 5_000_000;
console.log(validate(broken));

Output:

text
["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.

typescript
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:

text
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.

typescript
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:

text
["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.