Skip to content

02 · Decorators

Decorators let you attach reusable behavior to a class, method, accessor, or field by writing @something right above its declaration — logging, retry logic, validation, dependency injection, and ORMs like TypeORM all lean on this mechanism. This module uses TypeScript's modern standard decorators (the TC39 Stage 3 proposal, TypeScript's default since 5.0) — no experimentalDecorators flag required, and they check cleanly under --strict.

Standard decorators vs. legacy experimentalDecorators

Older TypeScript code (and some frameworks, like older NestJS/Angular versions) uses a different, earlier decorator implementation enabled by "experimentalDecorators": true in tsconfig.json. The syntax looks similar but the underlying types differ. If you're adding decorators to an existing project and these examples don't quite match what you see, check for that flag first.

Method decorators

A method decorator receives the original method and a context object describing it, and returns a replacement function:

function logged<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(`Calling ${methodName} with`, args);
    const result = target.call(this, ...args);
    console.log(`${methodName} returned`, result);
    return result;
  };
}

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

const calc = new Calculator();
calc.add(2, 3);
Calling add with [ 2, 3 ]
add returned 5

The type parameters (This, Args, Return) are what keep this generic: logged works on any method, on any class, with any argument list, and the decorated method keeps its original call signature from the outside — callers of calc.add(2, 3) see nothing different.

Decorator factories — decorators that take arguments

A decorator itself can't take extra arguments directly, but a function that returns a decorator can. This is a decorator factory, and it's the pattern behind almost every configurable decorator you'll encounter:

function retry(times: number) {
  return function <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 {
      let lastError: unknown;
      for (let attempt = 1; attempt <= times; attempt++) {
        try {
          return target.call(this, ...args);
        } catch (err) {
          lastError = err;
          console.log(`${methodName} attempt ${attempt} failed`);
        }
      }
      throw lastError;
    };
  };
}

class FlakyService {
  private attempts = 0;

  @retry(3)
  fetchData(): string {
    this.attempts += 1;
    if (this.attempts < 3) {
      throw new Error(`not ready (attempt ${this.attempts})`);
    }
    return "data!";
  }
}

const service = new FlakyService();
console.log(service.fetchData());
fetchData attempt 1 failed
fetchData attempt 2 failed
data!

@retry(3) calls retry(3) first, which returns the real method decorator with times captured in its closure — that's the whole trick behind "a decorator with arguments."

Class decorators

A class decorator receives the class constructor itself and can return a new constructor to replace it entirely — commonly used to wrap construction with extra setup:

function sealed<T extends new (...args: any[]) => object>(
  target: T,
  context: ClassDecoratorContext<T>
): T {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      Object.seal(this);
    }
  };
}

@sealed
class Point {
  x = 0;
  y = 0;
}

const p = new Point();
p.x = 10;
console.log(p.x);
console.log(Object.isSealed(p));   // true -- @sealed locked the instance shape
10
true

Object.seal is a runtime guarantee (it prevents adding new properties), separate from TypeScript's compile-time structural checks — @sealed demonstrates a decorator enforcing something at runtime that the type system alone can't.

Auto-accessor decorators

Fields declared with the accessor keyword get an implicit private backing field plus a get/set pair, which a decorator can intercept — useful for logging, validation, or change-tracking on a property:

function logAccess<This, Value>(
  target: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
  const fieldName = String(context.name);
  return {
    get(this: This): Value {
      const value = target.get.call(this);
      console.log(`reading ${fieldName}:`, value);
      return value;
    },
    set(this: This, value: Value): void {
      console.log(`writing ${fieldName}:`, value);
      target.set.call(this, value);
    },
  };
}

class Counter {
  @logAccess
  accessor count = 0;
}

const counter = new Counter();
counter.count = 5;
console.log(counter.count);
writing count: 5
reading count: 5
5

Note accessor count = 0, not count = 0 — plain fields can only be decorated in a much more limited way (they don't support intercepting reads/writes like this); accessor is what unlocks the full get/set decorator shape.

A trap: decorator evaluation order surprises people

Decorators run bottom-up when a declaration has more than one, but the factory calls that produce them run top-down:

function first() {
  console.log("first: factory evaluated");
  return function (target: unknown, context: ClassMethodDecoratorContext) {
    console.log("first: decorator applied");
  };
}

function second() {
  console.log("second: factory evaluated");
  return function (target: unknown, context: ClassMethodDecoratorContext) {
    console.log("second: decorator applied");
  };
}

class Demo {
  @first()
  @second()
  method() {}
}
// second: factory evaluated
// first: factory evaluated
// second: decorator applied
// first: decorator applied

If you stack decorators (logging, then validation, then caching, for example) and the combined behavior looks backwards, this ordering — not a bug in your decorator — is almost always why.

How It Actually Works

Unlike almost every other TypeScript-only construct, decorators are not type-erased — they compile to real function calls that execute at class-definition time, not at instantiation time. @log class Foo {} compiles (in the legacy experimentalDecorators model) to something like Foo = __decorate([log], Foo), where __decorate is a runtime helper the compiler emits once per file that uses decorators, and it calls your decorator function immediately as the class declaration is evaluated, passing it the constructor (or property descriptor) to optionally replace or wrap. This is why decorator evaluation order is a real runtime sequencing question, not a type-checking abstraction: decorators run bottom-to-top within a single declaration, and property/method decorators run before class decorators, because that mirrors the order the underlying descriptors actually become available during class construction.

TypeScript's decorator support went through a genuine breaking change: experimentalDecorators implements TypeScript's own pre-standard proposal (the __decorate/__param/__metadata helpers, still widely used by frameworks like Angular and NestJS), while newer TypeScript versions also support the stage-3 ECMAScript decorators standard, which has a different runtime calling convention (decorators receive a context object, not raw property descriptors) and compiles to different emitted helper code — the two are not interchangeable, and mixing decorator syntax written for one model against a tsconfig targeting the other produces confusing runtime failures rather than compile errors, because both are syntactically valid decorator positions to the parser.

reflect-metadata (paired with emitDecoratorMetadata) is where decorators intersect with the type system in an unusual way: the compiler, when this flag is set, emits calls that store a runtime-accessible representation of a parameter or property's declared type (as a constructor-function value, like String or Number, not the full structural TypeScript type) as metadata on the class — this is the one narrow case where type information does leak into runtime-inspectable form, and it's exactly what dependency-injection frameworks use to look up which class to construct for a given constructor parameter, since ordinary generic-parameter erasure would otherwise make that impossible.

Cheat sheet

Decorator kind Signature receives Typical use
Method (target, context: ClassMethodDecoratorContext) Logging, retry, memoization
Class (target, context: ClassDecoratorContext) Sealing, registration, mixins
Accessor (target, context: ClassAccessorDecoratorContext) Validation, change tracking
Field (value, context: ClassFieldDecoratorContext) Default-value transforms
Decorator factory function that returns any of the above Configurable decorators, e.g. @retry(3)
Stacking order bottom decorator's factory runs first; decorators apply bottom-up Matters when order affects behavior

Exercise

Write a method decorator factory @memoize() that caches a method's return value keyed by JSON.stringify(args), so repeated calls with the same arguments skip re-running the method body (log something inside the method so you can see it only executes once per distinct argument list). Apply it to a deliberately slow method (e.g. one with a for loop doing extra work) and call it three times with two different argument sets to prove the cache hits.