Skip to main content
TypeScript · lesson 12 of 21
TypeScript advanced Lesson 12 of 21

Decorators in TypeScript

Standard TC39 decorators for methods, classes, accessors and fields, then legacy experimentalDecorators with metadata-driven DI — all with real output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. Node can’t run decorators itself, so tsx (or tsc) has to transform them first.

What Is a Decorator?

A decorator is a function applied with @ to a class or class member. For a method, it receives the original method and a context object, and may return a replacement.

function logged<This, Args extends unknown[], Return>(
  method: (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 {
    console.log(`-> ${name}(${args.join(", ")})`);
    const result = method.call(this, ...args);
    console.log(`<- ${name} = ${result}`);
    return result;
  };
}

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

console.log(new Calculator().add(2, 3) * 10);
$ npx tsc --noEmit --module nodenext logged.ts
$ npx tsx logged.ts
-> add(2, 3)
<- add = 5
50

The method body never mentions logging. The generic parameters make the replacement keep add’s exact signature, and the compiler checks that it does. These are standard (TC39) decorators — no compiler flag is needed. Node itself can’t parse them:

$ node logged.ts 2>&1 | grep -m1 Error
SyntaxError: Invalid or unexpected token

Decorators with Arguments, and Their Order

A decorator factory is a function that returns a decorator, so it can take options. When several decorators stack, they’re applied bottom-up — the one nearest the method wraps it first.

type Method<T, A extends unknown[], R> = (this: T, ...args: A) => Promise<R>;

function retry(times: number) {
  return function <T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
    return async function (this: T, ...args: A): Promise<R> {
      for (let attempt = 1; ; attempt++) {
        try {
          return await method.call(this, ...args);
        } catch (err) {
          console.log(`  ${String(ctx.name)} attempt ${attempt} failed: ${(err as Error).message}`);
          if (attempt === times) throw err;
        }
      }
    };
  };
}

function timed<T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
  return async function (this: T, ...args: A): Promise<R> {
    const start = Date.now();
    try {
      return await method.call(this, ...args);
    } finally {
      console.log(`  ${String(ctx.name)} took ~${Math.round((Date.now() - start) / 50) * 50}ms`);
    }
  };
}

class Api {
  calls = 0;
  @timed
  @retry(3)
  async fetchUser(id: number): Promise<string> {
    await new Promise((r) => setTimeout(r, 50));
    if (++this.calls < 3) throw new Error("503");
    return `user ${id}`;
  }
}

console.log(await new Api().fetchUser(7));
$ npx tsc --noEmit --module nodenext retry.ts
$ npx tsx retry.ts
  fetchUser attempt 1 failed: 503
  fetchUser attempt 2 failed: 503
  fetchUser took ~150ms
user 7

@retry wraps the method first, then @timed wraps the retrying version — so the time covers all three attempts (rounded to 50 ms). Swap the two lines and @timed would wrap a single attempt instead, printing one timing per attempt (Practice 1 shows it). Decorator order is part of the behaviour.

Class Decorators and addInitializer

Every context object has addInitializer, which registers code to run when the class is defined (for class decorators) or when each instance is constructed (for member decorators). A classic use is auto-binding methods:

function bound<This>(method: (this: This, ...args: any[]) => any, ctx: ClassMethodDecoratorContext<This>) {
  ctx.addInitializer(function (this: This) {
    (this as any)[ctx.name] = method.bind(this);
  });
}

const registry: string[] = [];
function register(value: Function, ctx: ClassDecoratorContext) {
  ctx.addInitializer(() => registry.push(String(ctx.name)));
}

@register
class Counter {
  count = 0;
  @bound
  increment() {
    return ++this.count;
  }
  decrement() {
    return --this.count;
  }
}

console.log("registered:", registry);
const c = new Counter();
const inc = c.increment;
console.log(inc(), inc());
const dec = c.decrement;
try {
  dec();
} catch (e) {
  console.log("unbound:", (e as Error).message);
}
$ npx tsc --noEmit --module nodenext bound.ts
$ npx tsx bound.ts
registered: [ 'Counter' ]
1 2
unbound: Cannot read properties of undefined (reading 'count')

registry was filled when the class definition ran, before any instance existed — handy for plugin or route registration. The detached increment keeps working because the initializer bound it to each instance; the undecorated decrement loses this.

Accessor and Field Decorators

Standard decorators can’t intercept plain fields’ reads and writes, but the accessor keyword creates a getter/setter pair backed by private storage, and an accessor decorator can wrap both.

function range(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    ctx: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    const check = (v: number) => {
      if (v < min || v > max) throw new RangeError(`${String(ctx.name)} must be ${min}..${max}, got ${v}`);
      return v;
    };
    return {
      set(value) { target.set.call(this, check(value)); },
      init: check,
    };
  };
}

function trimmed<This>(_: undefined, ctx: ClassFieldDecoratorContext<This, string>) {
  return (initial: string) => initial.trim();
}

class Product {
  @trimmed name = "  Widget  ";
  @range(0, 10_000) accessor price = 10;
}

const p = new Product();
p.price = 99.99;
console.log(JSON.stringify(p.name), p.price);
try {
  p.price = 99_999;
} catch (e) {
  console.log((e as Error).message, "- price still", p.price);
}
$ npx tsc --noEmit --module nodenext accessor.ts
$ npx tsx accessor.ts
"Widget" 99.99
price must be 0..10000, got 99999 - price still 99.99

init validates the initial value too, so accessor price = -5 would throw at construction. A field decorator gets undefined as its first argument — there’s nothing to wrap yet — and can only return a function that transforms the initial value; later assignments to name are not intercepted.

Legacy Decorators (experimentalDecorators)

Angular, NestJS, TypeORM and class-validator still use TypeScript’s older decorator system. It has a different signature (target, propertyKey, descriptor), adds parameter decorators, and — with emitDecoratorMetadata — records constructor parameter types at runtime. Mixing the two systems isn’t possible, so it lives in its own project:

{
  "compilerOptions": {
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
import "reflect-metadata";

const providers = new Map<unknown, unknown>();

function Injectable(): ClassDecorator {
  return (target) => { providers.set(target, undefined); };
}

function resolve<T>(Ctor: new (...args: any[]) => T): T {
  const deps: (new () => unknown)[] = Reflect.getMetadata("design:paramtypes", Ctor) ?? [];
  console.log(`resolving ${Ctor.name}(${deps.map((d) => d.name).join(", ")})`);
  return new Ctor(...deps.map((d) => resolve(d)));
}

@Injectable()
class Logger {
  log(msg: string) { console.log(`[log] ${msg}`); }
}

@Injectable()
class UserService {
  constructor(private logger: Logger) {}
  create(name: string) { this.logger.log(`created ${name}`); }
}

resolve(UserService).create("Ada");
$ npx tsc -p legacy
$ node legacy/dist/di.js
resolving UserService(Logger)
resolving Logger()
[log] created Ada
$ grep -n "design:paramtypes" legacy/dist/di.js
16:    const deps = Reflect.getMetadata("design:paramtypes", Ctor) ?? [];
35:    __metadata("design:paramtypes", [Logger])

emitDecoratorMetadata made the compiler write __metadata("design:paramtypes", [Logger]) into the output for every decorated class — the one place TypeScript types survive to runtime. That’s how Nest and Angular know what to inject without being told. It only works for classes (an interface has no runtime value to record), and it requires the reflect-metadata polyfill.

A Worked Example: Declarative Validation

A class-validator-style system, using standard decorators: each decorator records a rule in context.metadata, and a validate function reads the rules back.

(Symbol as { metadata?: symbol }).metadata ??= Symbol("Symbol.metadata");

type Rule = (value: unknown) => string | null;

function rule(check: Rule) {
  return (_: undefined, ctx: ClassFieldDecoratorContext) => {
    const rules = ((ctx.metadata.rules ??= {}) as Record<string, Rule[]>);
    (rules[String(ctx.name)] ??= []).push(check);
  };
}

const MinLength = (n: number) => rule((v) => (typeof v === "string" && v.length >= n ? null : `must be at least ${n} characters`));
const IsEmail = rule((v) => (typeof v === "string" && /^[^\s@]+@[^\s@]+$/.test(v) ? null : "must be an email"));

function validate(obj: object): string[] {
  const meta = (obj.constructor as unknown as Record<symbol, { rules?: Record<string, Rule[]> } | undefined>)[Symbol.metadata];
  return Object.entries(meta?.rules ?? {}).flatMap(([field, rules]) =>
    rules.map((r) => r((obj as Record<string, unknown>)[field])).filter((m) => m !== null).map((m) => `${field} ${m}`),
  );
}

class CreateUserDto {
  @MinLength(2) name = "";
  @IsEmail email = "";
  constructor(init: Partial<CreateUserDto>) { Object.assign(this, init); }
}

console.log(validate(new CreateUserDto({ name: "Ada", email: "ada@example.com" })));
console.log(validate(new CreateUserDto({ name: "A", email: "nope" })));
$ npx tsc --noEmit --module nodenext validation.ts
validation.ts(7,21): error TS18048: 'ctx.metadata' is possibly 'undefined'.
validation.ts(16,118): error TS2550: Property 'metadata' does not exist on type 'SymbolConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.
$ npx tsc --noEmit --module nodenext --target esnext validation.ts
$ npx tsx validation.ts
[]
[ 'name must be at least 2 characters', 'email must be an email' ]

context.metadata is an object shared by every decorator on the class and attached to the class as Class[Symbol.metadata] — the standard replacement for reflect-metadata. It’s new enough that two things lag behind. TypeScript 6’s default lib doesn’t declare Symbol.metadata (and so types ctx.metadata as possibly undefined), so the file only type-checks with --target esnext, or "lib": ["esnext"] in a config. And Node 22 doesn’t define Symbol.metadata at runtime, so the first line polyfills it; without that, the metadata is never attached and validate finds no rules.

Practice

1. Swap the order of @timed and @retry(3) in retry.ts. How many timing lines appear, and why?

Solution
type Method<T, A extends unknown[], R> = (this: T, ...args: A) => Promise<R>;

function retry(times: number) {
  return function <T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
    return async function (this: T, ...args: A): Promise<R> {
      for (let attempt = 1; ; attempt++) {
        try {
          return await method.call(this, ...args);
        } catch (err) {
          console.log(`  ${String(ctx.name)} attempt ${attempt} failed`);
          if (attempt === times) throw err;
        }
      }
    };
  };
}

function timed<T, A extends unknown[], R>(method: Method<T, A, R>, ctx: ClassMethodDecoratorContext) {
  return async function (this: T, ...args: A): Promise<R> {
    const start = Date.now();
    try {
      return await method.call(this, ...args);
    } finally {
      console.log(`  ${String(ctx.name)} took ~${Math.round((Date.now() - start) / 50) * 50}ms`);
    }
  };
}

class Api {
  calls = 0;
  @retry(3)
  @timed
  async fetchUser(id: number): Promise<string> {
    await new Promise((r) => setTimeout(r, 50));
    if (++this.calls < 3) throw new Error("503");
    return `user ${id}`;
  }
}

console.log(await new Api().fetchUser(7));
$ npx tsx practice-1.ts
  fetchUser took ~50ms
  fetchUser attempt 1 failed
  fetchUser took ~50ms
  fetchUser attempt 2 failed
  fetchUser took ~50ms
user 7

Three: @timed is now nearest the method, so it wraps a single attempt, and @retry calls that timed version three times.

2. Write a @memo method decorator that caches results by the first argument, and show a slow method only running once per input.

Solution
function memo<This, A, R>(method: (this: This, arg: A) => R, _ctx: ClassMethodDecoratorContext) {
  const cache = new Map<A, R>();
  return function (this: This, arg: A): R {
    if (!cache.has(arg)) cache.set(arg, method.call(this, arg));
    return cache.get(arg)!;
  };
}

class MathService {
  runs = 0;
  @memo
  fib(n: number): number {
    this.runs++;
    return n < 2 ? n : this.fib(n - 1) + this.fib(n - 2);
  }
}

const m = new MathService();
console.log(m.fib(40), "computed in", m.runs, "runs");
$ npx tsc --noEmit --module nodenext practice-2.ts
$ npx tsx practice-2.ts
102334155 computed in 41 runs

41 runs instead of hundreds of millions, because the recursive this.fib calls go through the decorated method too. Note the cache is shared by every instance of the class, since the decorator runs once per class definition — put it in an addInitializer if it must be per-instance.

3. Add a @range(0, 150) accessor age to Product-style class Person and show that an invalid initial value throws at construction.

Solution
function range(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    ctx: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    const check = (v: number) => {
      if (v < min || v > max) throw new RangeError(`${String(ctx.name)} must be ${min}..${max}, got ${v}`);
      return v;
    };
    return { set(value) { target.set.call(this, check(value)); }, init: check };
  };
}

class Person {
  @range(0, 150) accessor age: number;
  constructor(age: number) { this.age = age; }
}

class Broken {
  @range(0, 150) accessor age = -1;
}

console.log(new Person(36).age);
try {
  new Broken();
} catch (e) {
  console.log((e as Error).message);
}
$ npx tsc --noEmit --module nodenext practice-3.ts
$ npx tsx practice-3.ts
36
age must be 0..150, got -1

Person has no initialiser, so init sees undefined, which isn’t below 0 or above 150 — that’s why it passes; the constructor’s assignment then goes through the checked set. Broken’s -1 fails in init.

Frequently Asked Questions

Are decorators stable in TypeScript?
TypeScript 5.0 implemented the TC39 Stage 3 decorator proposal, which is the standard going forward. The older experimentalDecorators flag enables the legacy decorator system used by Angular and NestJS. The two are incompatible.
Do I need to enable anything to use decorators?
For legacy decorators (used by Angular, NestJS): set experimentalDecorators: true in tsconfig.json. For TC39 Stage 3 decorators (TypeScript 5.0+): no flag needed — they work by default.
Can decorators change a method's return type?
In the standard decorator proposal, decorators cannot change the types of what they decorate — they are transparent to the type system. The decorated method must have the same type signature as the original.
Can Node run decorators natively?
Not yet. Node 22 has no decorator support, and its built-in type stripping rejects the @ syntax. Run decorated code through tsc, tsx or a bundler, which rewrite decorators into plain function calls.
Can standard decorators decorate constructor parameters?
No. The TC39 proposal covers classes, methods, getters, setters, fields and auto-accessors, but not parameters. Parameter decorators, and the emitDecoratorMetadata type information that NestJS and Angular dependency injection rely on, exist only in the legacy experimentalDecorators mode.