Decorators

TypeScript · Lesson 23 of 28 7 min read Updated

A decorator is a function that you attach to a class or class member with the @ symbol, such as @logged. It can wrap a method to add logging, check the arguments, change how a field is stored, or register a class somewhere. Frameworks like Angular and NestJS use decorators heavily, so you will meet them sooner or later.

There are two different kinds of decorators in TypeScript: the standard decorators that follow the JavaScript (TC39) proposal, supported since TypeScript 5.0, and the older legacy decorators enabled with experimentalDecorators. They look the same in code but work differently. This lesson teaches the standard ones first and then shows the legacy style you will see in existing projects.

Your first method decorator

A standard decorator is a function that receives two arguments: the thing being decorated (here, the original method) and a context object with information such as its name and kind. If the decorator returns a new function, that function replaces the method.

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

class FeeCalculator {
  @logged
  total(monthlyFee: number, months: number): number {
    return monthlyFee * months;
  }
}

const calc = new FeeCalculator();
calc.total(1500, 3);
calc.total(2200, 12);
Output
-> total(1500, 3)
<- total returned 4500
-> total(2200, 12)
<- total returned 26400

The generic parameters This, Args and Return keep the decorator fully typed: the replacement function has exactly the same parameters and return type as the original. ClassMethodDecoratorContext is a built-in type that describes the context for methods. Its most useful properties are name, kind (here "method"), static, private and the addInitializer() function.

Without the decorator you would have to add the two console.log lines inside every method you want to trace. With it, one word above a method is enough, and the logging code lives in one place.

Decorators are a tidy syntax for something JavaScript could always do by hand: replace a method with a wrapped version of itself. This runnable example does the same job without the @ syntax, so you can see the idea in the editor:

TypeScriptRun it yourself
class FeeCalculator {
  total(monthlyFee: number, months: number): number {
    return monthlyFee * months;
  }
}

function withLog<Args extends unknown[], Return>(name: string, fn: (...args: Args) => Return) {
  return function (this: unknown, ...args: Args): Return {
    console.log(`-> ${name}(${args.join(", ")})`);
    return fn.apply(this, args);
  };
}

// roughly what @logged does for you when the class is defined
FeeCalculator.prototype.total = withLog("total", FeeCalculator.prototype.total);

console.log(new FeeCalculator().total(1500, 3));
Output
-> total(1500, 3)
4500

Decorator factories

To configure a decorator, write a function that returns a decorator. This is called a decorator factory, and you call it with brackets: @range(1, 100000).

TypeScript
function range(min: number, max: number) {
  return function <This>(
    original: (this: This, amount: number) => string,
    context: ClassMethodDecoratorContext<This, (this: This, amount: number) => string>,
  ) {
    return function (this: This, amount: number): string {
      if (amount < min || amount > max) {
        throw new RangeError(`${String(context.name)}: amount must be ₹${min} to ₹${max}`);
      }
      return original.call(this, amount);
    };
  };
}

class UpiPayment {
  @range(1, 100000)
  pay(amount: number): string {
    return `Paid ₹${amount} by UPI`;
  }
}

const upi = new UpiPayment();
console.log(upi.pay(2499));
try {
  upi.pay(250000);
} catch (err) {
  console.log(err instanceof Error ? err.message : err);
}
Output
Paid ₹2499 by UPI
pay: amount must be ₹1 to ₹100000

The rule "amount must be within a range" is now reusable on any method that takes an amount, and the limits are visible right above the method.

Accessor decorators

Decorators can also be applied to auto-accessors, a new kind of class member written with the accessor keyword. accessor marks = 0 creates a hidden private field plus a getter and setter for it. An accessor decorator can return new get, set and init functions:

TypeScript
function clamp(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    _context: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    const fit = (value: number) => Math.min(max, Math.max(min, value));
    return {
      init: (value) => fit(value),
      set(value) {
        target.set.call(this, fit(value));
      },
    };
  };
}

class ReportCard {
  name: string;
  @clamp(0, 100) accessor marks = 0;
  @clamp(0, 100) accessor attendance = 120;

  constructor(name: string) {
    this.name = name;
  }
}

const card = new ReportCard("Isha");
card.marks = 112;
console.log(card.name, card.marks, card.attendance);
card.marks = 87;
console.log(card.marks);
Output
Isha 100 100
87

Every assignment to marks, including the starting value of attendance, passes through fit, so the values can never leave the 0 to 100 range.

Class decorators

A class decorator receives the class itself. A common use is to register classes, for example payment gateways that the app can choose from by name:

TypeScript
type Gateway = { pay(amount: number): string };
const gateways = new Map<string, new () => Gateway>();

function gateway(name: string) {
  return function <T extends new () => Gateway>(target: T, _context: ClassDecoratorContext<T>) {
    gateways.set(name, target);
  };
}

@gateway("upi")
class UpiGateway {
  pay(amount: number) {
    return `UPI request for ₹${amount}`;
  }
}

@gateway("card")
class CardGateway {
  pay(amount: number) {
    return `Card charge of ₹${amount}`;
  }
}

for (const [name, Gateway] of gateways) {
  console.log(name, "=>", new Gateway().pay(799));
}
Output
upi => UPI request for ₹799
card => Card charge of ₹799

The decorators run once, when each class is defined, so the gateways map is filled before any other code runs. Since TypeScript 5.2, decorators can also attach data to context.metadata, which frameworks use to store information such as validation rules.

Legacy decorators (experimentalDecorators)

Before the JavaScript proposal settled, TypeScript shipped its own decorator design behind the experimentalDecorators option. Angular, NestJS, TypeORM and many other libraries were built on it, often together with emitDecoratorMetadata, which writes extra type information for dependency injection. TypeScript 7 still supports these options, so existing projects keep working.

Legacy method decorators have a different signature: they receive the class prototype, the member name and a property descriptor:

TypeScript
// tsconfig.json: "experimentalDecorators": true
function logged(target: object, key: string, descriptor: PropertyDescriptor): void {
  const original = descriptor.value;
  descriptor.value = function (this: unknown, ...args: unknown[]) {
    console.log(`-> ${key}(${args.join(", ")})`);
    return original.apply(this, args);
  };
}

class FeeCalculator {
  @logged
  total(monthlyFee: number, months: number): number {
    return monthlyFee * months;
  }
}

console.log(new FeeCalculator().total(1500, 3));
Output
-> total(1500, 3)
4500
Standard decoratorsLegacy decorators
OptionNone needed (TypeScript 5.0+)"experimentalDecorators": true
Arguments(value, context)(target, key, descriptor)
Parameter decoratorsNot supportedSupported (used for dependency injection)
emitDecoratorMetadataNot usedSupported
FutureFollows the JavaScript standardKept for existing code

You cannot mix the two styles in one project. Use standard decorators for new code, and stay with legacy decorators when your framework requires them; check the framework's documentation.

Common mistakes

  • Copying legacy decorator code into a standard project (or the reverse). The signatures differ; check whether experimentalDecorators is on.
  • Expecting decorators to run in plain Node.js or the browser. They need a compile step until JavaScript engines support them.
  • Forgetting the brackets on a factory. @range without (1, 100000) passes the method to range as min, which is a type error.
  • Hiding too much logic in decorators. A decorator that changes behaviour in surprising ways makes code hard to follow. Keep them small and well named.

Key points

  • A decorator is a function applied with @name to a class, method, accessor or field.
  • Standard decorators (TypeScript 5.0+) receive the original value and a context object, and may return a replacement.
  • A decorator factory such as @range(1, 100000) is a function that returns a decorator.
  • accessor fields with decorators can transform every read and write; class decorators can register classes.
  • Legacy experimentalDecorators use a different (target, key, descriptor) signature, are still supported in TypeScript 7, and are common in Angular and NestJS code.

Exercise

Write a standard method decorator timed that measures how long a method takes with performance.now() and logs methodName took X ms (rounded to whole milliseconds). Apply it to a method generateReport(students: number) in a class SchoolReports that builds an array of that many report strings and returns its length. Call it with 50,000 students.

Show solution
TypeScript
function timed<This, Args extends unknown[], Return>(
  original: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  return function (this: This, ...args: Args): Return {
    const start = performance.now();
    const result = original.call(this, ...args);
    const ms = Math.round(performance.now() - start);
    console.log(`${String(context.name)} took ${ms} ms`);
    return result;
  };
}

class SchoolReports {
  @timed
  generateReport(students: number): number {
    const reports: string[] = [];
    for (let i = 1; i <= students; i++) {
      reports.push(`Roll ${i}: promoted`);
    }
    return reports.length;
  }
}

console.log(new SchoolReports().generateReport(50000));
// generateReport took 6 ms   (the time depends on your computer)
// 50000

The decorator wraps the original method, measures the time around the call and returns the original result, so callers see no difference except the log line.

Quick Quiz

1. What does a standard method decorator receive?

2. What is @range(1, 100000)?

3. Which option enables the older decorator style used by many Angular and NestJS projects?

4. Why do decorators fail with Node.js type stripping?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call