Skip to lesson

learningtypescript.org / advanced / 11-decorators · lesson 23 of 25

TL;DR

Learn TypeScript decorators to add metadata and behavior to classes, methods, and properties. Used in Angular, NestJS, and TypeORM.

Key concepts

  • TypeScript decorators
  • class decorators
  • method decorators TypeScript
  • decorator pattern

Decorators

Decorators are a way to add annotations and modify classes and their members with the @decorator syntax. They're widely used in frameworks like Angular, NestJS, and TypeORM.

How this lesson runs: these examples use standard TC39 decorators — the version TypeScript 5+ supports by default, matching the ECMAScript proposal, with the (value, context) signature. This is exactly what the runner executes and what tsc --strict accepts, with no experimentalDecorators flag required. An older, legacy decorator design used a different (target, key, descriptor) signature and needed that flag plus reflect-metadata; parameter decorators and emitDecoratorMetadata belong to that legacy world and do not run here. We cover the standard model throughout; the legacy design is noted only as history at the end.

What Are Decorators?

A decorator is a function that receives the thing being decorated (value) plus a context object describing it (its kind, its name, and hooks like addInitializer). The function can observe or replace what it decorates.

// A class decorator receives (value, context). value is the class itself;
// context.kind is "class" and context.name is the class name.
function logged<T extends new (...args: any[]) => any>(
  value: T,
  context: ClassDecoratorContext,
): T {
  console.log(`Class decorated: ${String(context.name)}`);
  return value;
}

@logged
class UserService {
  getUser(id: number) {
    return { id, name: "Alice" };
  }
}

// A decorator FACTORY takes options and returns a decorator — that's what lets
// you write @component({ ... }) with arguments.
function component(options: { name: string; version: string }) {
  return function <T extends new (...args: any[]) => any>(
    value: T,
    context: ClassDecoratorContext,
  ): T {
    console.log(`Registered ${options.name} v${options.version} (${String(context.name)})`);
    return value;
  };
}

@component({ name: "AppHeader", version: "1.0" })
class AppHeader {
  render() {
    return "<header>App</header>";
  }
}

const svc = new UserService();
console.log(svc.getUser(7));
console.log(new AppHeader().render());

Method Decorators

A method decorator receives the original method as value and can return a replacement function to wrap it. The wrapper runs your code around each call, then delegates to the original with original.apply(this, args).

// A method decorator receives (originalMethod, context). Returning a replacement
// function wraps the method; context.name is the method's name.
function log(
  original: (...args: any[]) => any,
  context: ClassMethodDecoratorContext,
) {
  const name = String(context.name);
  return function (this: unknown, ...args: any[]) {
    console.log(`Calling ${name}(${args.join(", ")})`);
    const result = original.apply(this, args);
    console.log(`  -> returned: ${JSON.stringify(result)}`);
    return result;
  };
}

// Each wrapper closes over its own state — here, a call counter.
function count(
  original: (...args: any[]) => any,
  context: ClassMethodDecoratorContext,
) {
  const name = String(context.name);
  let calls = 0;
  return function (this: unknown, ...args: any[]) {
    calls++;
    console.log(`${name} call #${calls}`);
    return original.apply(this, args);
  };
}

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

  @count
  square(n: number): number {
    return n * n;
  }
}

const calc = new Calculator();
calc.add(3, 4);
console.log(`square(5) = ${calc.square(5)}`);
console.log(`square(9) = ${calc.square(9)}`);

The original.apply(this, args) call is what forwards to the real method with the correct this. Returning a new function from a method decorator is how you add logging, timing, caching, or retry behavior without touching the method body.

Accessor and Field Decorators

Standard decorators reach beyond methods. An accessor decorator wraps an auto-accessor's get/set — ideal for validating every assignment. A field decorator returns an initializer transform that runs on the field's initial value and returns the value it should actually start with.

// An accessor decorator wraps the get/set of an `accessor` field. Here it validates
// every assignment against a numeric range, throwing on an out-of-range value.
function range(min: number, max: number) {
  return function (
    target: ClassAccessorDecoratorTarget<unknown, number>,
    context: ClassAccessorDecoratorContext<unknown, number>,
  ): ClassAccessorDecoratorResult<unknown, number> {
    const name = String(context.name);
    return {
      get() {
        return target.get.call(this);
      },
      set(value: number) {
        if (value < min || value > max) {
          throw new RangeError(`${name} must be in [${min}, ${max}], got ${value}`);
        }
        target.set.call(this, value);
      },
    };
  };
}

// A field decorator returns an initializer transform: it receives the field's
// initial value and returns the value the field should actually hold.
function defaultTo(fallback: string) {
  return function (_value: undefined, context: ClassFieldDecoratorContext<unknown, string>) {
    return function (this: unknown, initial: string): string {
      return initial.length > 0 ? initial : fallback;
    };
  };
}

class Settings {
  @range(0, 11) accessor volume: number = 5;
  @defaultTo("guest") name: string = "";
}

const s = new Settings();
console.log(`volume: ${s.volume}, name: ${s.name}`); // name fell back to "guest"

s.volume = 9;
console.log(`volume after set: ${s.volume}`);

try {
  s.volume = 99;
} catch (e) {
  console.log((e as Error).message);
}

Note the accessor keyword on volume — an accessor decorator can only decorate an auto-accessor field, which the compiler backs with a private slot plus a generated getter/setter. The field decorator on name never sees a getter; it just transforms the initial value, which is why an empty name becomes "guest".

Decorator Factories

You have already used factories — range(0, 11) and defaultTo("guest") are decorator factories. A factory is a function that takes configuration and returns the actual decorator, which is what lets you pass arguments at the @ site. Here's one more: a retry factory that wraps a method to re-run it on failure.

// A decorator factory that returns a METHOD decorator: retry wraps the method so
// it re-invokes on failure up to maxAttempts times before rethrowing.
function retry(maxAttempts: number) {
  return function (
    original: (...args: any[]) => any,
    context: ClassMethodDecoratorContext,
  ) {
    const name = String(context.name);
    return function (this: unknown, ...args: any[]) {
      let lastError: unknown;
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return original.apply(this, args);
        } catch (error) {
          lastError = error;
          console.log(`  ${name} attempt ${attempt}/${maxAttempts} failed: ${(error as Error).message}`);
        }
      }
      throw lastError;
    };
  };
}

class ApiClient {
  private callCount = 0;

  @retry(5)
  fetchData(): string {
    this.callCount++;
    if (this.callCount < 3) {
      throw new Error("Network timeout");
    }
    return "Data received!";
  }
}

const client = new ApiClient();
console.log(`Result: ${client.fetchData()}`);

The wrapper closes over maxAttempts and forwards each attempt with original.apply(this, args), so the method's own this.callCount keeps incrementing across retries until the third call succeeds.

Predict

Two method decorators are stacked on run, and each logs when it applies. The program logs before class, then defines the class, then creates an instance. Decorators run when the class is DEFINED (not when an instance is made), and stacked decorators apply closest-to-the-method first. This runs in the tsx runner. Predict the full output, in order.

function first(_v: any, _c: ClassMethodDecoratorContext) {
console.log("first applies");
}
function second(_v: any, _c: ClassMethodDecoratorContext) {
console.log("second applies");
}

console.log("before class");

class Demo {
@first
@second
run(): void {}
}

console.log("class defined");
new Demo();
console.log("instance created");
Continue learning

Recall

Without scrolling up: the class decorator was typed function logged<T extends new (...args: any[]) => any>(value: T, ...): T. That T extends new (...args: any[]) => any constraint is a generic bound you met in 07-generics. What does that specific constraint say T must be, and why does returning T (rather than a plain class type) matter for a decorator?

Continue learning

Try It Yourself

You've written class, method, accessor, and field decorators, plus factories. Now combine two into one program: a method decorator that tracks calls and an accessor decorator factory that guards a value (the patterns from Method Decorators and Accessor and Field Decorators). Each decorator is stubbed to a no-op that returns its input unchanged. Run it as-is to see the first failure, implement that decorator, then work down until it prints All checks passed.

Build

Finish the build. Two standard TC39 decorators are stubbed as no-ops (they return their input, so the class still runs but the behavior is missing); the checks below them fail until each decorator does its job. Spec (from Method Decorators and Accessor and Field Decorators above): tracked is a method decorator that returns a wrapper which pushes context.name onto the shared callLog before forwarding to the original with original.apply(this, args), returning its result unchanged. min(n) is an accessor decorator factory whose set rejects any value below n with a thrown RangeError and otherwise forwards to the underlying setter; its get forwards unchanged. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// Standard TC39 decorators: (value, context). Build the two decorators so the
// checks pass. The Account class and the checks are locked — do not edit them.

// TODO 1: a method decorator. Return a wrapper that pushes context.name onto
//   callLog, then forwards to the original and returns its result unchanged.
function tracked(
original: (...args: any[]) => any,
context: ClassMethodDecoratorContext,
): (...args: any[]) => any {
return original;
}

// TODO 2: an accessor decorator factory. min(n)'s set must reject any value below
//   n with a thrown RangeError, otherwise forward to target.set; get forwards.
function min(n: number) {
return function (
	target: ClassAccessorDecoratorTarget<any, number>,
	context: ClassAccessorDecoratorContext<any, number>,
): ClassAccessorDecoratorResult<any, number> {
	return target;
};
}

const callLog: string[] = [];

class Account {
@min(0) accessor balance: number = 100;

@tracked
deposit(amount: number): number {
	this.balance = this.balance + amount;
	return this.balance;
}
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const acct = new Account();
assert.strictEqual(acct.deposit(50), 150, "TODO 1: deposit should still return the new balance");
assert.deepStrictEqual(callLog, ["deposit"], "TODO 1: the tracked decorator should log the method name on each call");

acct.deposit(25);
assert.deepStrictEqual(callLog, ["deposit", "deposit"], "TODO 1: each call appends to the log");

assert.strictEqual(acct.balance, 175, "TODO 2: valid sets should pass through the accessor");
assert.throws(() => { acct.balance = -1; }, /RangeError/, "TODO 2: min(0) should reject a negative balance");
assert.strictEqual(acct.balance, 175, "TODO 2: a rejected set must leave the balance unchanged");

console.log("All checks passed.");
console.log("balance:", acct.balance);
console.log("calls:", callLog);

Expected output: All checks passed. balance: 175 calls: [ 'deposit', 'deposit' ]

Continue learning

Once it passes, try two variations and predict each before running:

  1. Drop the forward in tracked. Change the wrapper to push the name but not return the call: callLog.push(String(context.name)); with no return original.apply(this, args); after it. Predict which check fails first before running. The log is still recorded, but the wrapper now returns undefined instead of the method's result, so TODO 1's first check fails first — AssertionError: TODO 1: deposit should still return the new balance, undefined !== 150. A wrapping decorator must forward the return value, or it silently swallows every result.
  2. Log context.kind instead of context.name. In tracked, push String(context.kind) rather than String(context.name). Predict which check fails first before running. context.kind is "method" for every method, so the log becomes ["method"] instead of ["deposit"], and TODO 1's second check fails first — deepStrictEqual reports ["method"] where ["deposit"] was expected. kind tells you what was decorated; name tells you which — the log wanted the name.

Arrange the code

A decorator wraps a function to transform its result. Reassemble the value-level version of that idea: a greeter, its plain output, an upper-cased version, and a bracketed label, then log it. The lines are shuffled; each const consumes the binding above it, so only one order runs top-to-bottom and prints [HI ADA].

  1. const loud = base.toUpperCase();
  2. const base = greet("ada");
  3. console.log(message);
  4. const message = `[${loud}]`;
  5. const greet = (name: string): string => `Hi ${name}`;
Continue learning

The dependency-injection container below needs no decorators at all — it is plain classes wiring themselves together. It is worth studying because DI is the pattern that decorator-heavy frameworks (Angular, NestJS) build on top of: the @Injectable() decorators you see there are sugar over exactly this kind of registry.

// A simple dependency injection container
class Container {
  private services = new Map<string, any>();

  register(name: string, factory: () => any): void {
    this.services.set(name, factory);
  }

  resolve<T>(name: string): T {
    const factory = this.services.get(name);
    if (!factory) throw new Error(`Service '${name}' not registered`);
    return factory();
  }
}

// Services
class Database {
  query(sql: string): string[] {
    return [`Result for: ${sql}`];
  }
}

class UserRepository {
  constructor(private db: Database) {}

  findAll(): string[] {
    return this.db.query("SELECT * FROM users");
  }
}

class UserController {
  constructor(private repo: UserRepository) {}

  listUsers(): void {
    const users = this.repo.findAll();
    console.log("Users:", users);
  }
}

// Wire up dependencies
const container = new Container();
container.register("database", () => new Database());
container.register("userRepo", () => new UserRepository(container.resolve("database")));
container.register("userController", () => new UserController(container.resolve("userRepo")));

const controller = container.resolve<UserController>("userController");
controller.listUsers();

A Note on Legacy Decorators

Everything above uses standard TC39 decorators — the (value, context) signature TypeScript 5+ compiles for you. The proposal is still working its way through TC39, so JavaScript engines don't run decorator syntax natively yet — your build step transforms it. You will still encounter an older, incompatible design in existing codebases and framework docs. Legacy decorators use a different signature and require the experimentalDecorators compiler flag; they are shown here only for recognition, and this fence is no-run because the runner does not execute the legacy form:

// LEGACY decorator (needs "experimentalDecorators": true in tsconfig).
// Signature is (target, propertyKey, descriptor) — NOT the standard (value, context).
function legacyLog(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`(legacy) calling ${key}`);
    return original.apply(this, args);
  };
  return descriptor;
}

class OldStyle {
  @legacyLog
  greet(name: string): string {
    return `Hi ${name}`;
  }
}

Two legacy-only features have no standard equivalent that runs here: parameter decorators (decorating a constructor argument) and emitDecoratorMetadata with the reflect-metadata library (which frameworks like older NestJS use for dependency injection by reading parameter types at runtime). Both depend on the legacy design and its flags, so neither runs in this sandbox. When you migrate a codebase from legacy to standard decorators, those are the two features you must replace with explicit code — the rest maps cleanly onto the (value, context) model you learned above.

Key Takeaways

  • Standard TC39 decorators are functions with a (value, context) signature — no experimentalDecorators flag required
  • context carries the decorated member's kind and name, plus hooks like addInitializer
  • A method decorator can return a replacement function to wrap the original, forwarding with original.apply(this, args)
  • Accessor decorators wrap an accessor field's get/set (ideal for validation); field decorators return an initializer transform
  • Decorator factories take configuration and return the decorator, enabling @decorator(args) syntax
  • Stacked decorators apply bottom-to-top (closest to the member first) at class-definition time
  • A separate legacy decorator design ((target, key, descriptor), experimentalDecorators, parameter decorators, emitDecoratorMetadata) still exists in older code but does not run under the standard model

Pro Tip: Use decorators for cross-cutting concerns that span many classes: logging, caching, authentication checks, validation. Keep business logic in the methods themselves. If a decorator does too much, split it into multiple focused decorators that compose well.

Next Steps

You've added behavior to classes with standard decorators, wrapping methods and fields at definition time. Next, you'll take the type system out of the class and into the UI layer — applying it to the most popular frontend framework: typing React components, props, hooks, events, and context for a fully type-safe frontend.

Ready to continue? Head to React and TypeScript!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.