Decorators
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.
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);-> 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:
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));-> 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).
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);
}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:
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);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:
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));
}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:
// 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));-> total(1500, 3) 4500
| Standard decorators | Legacy decorators | |
|---|---|---|
| Option | None needed (TypeScript 5.0+) | "experimentalDecorators": true |
| Arguments | (value, context) | (target, key, descriptor) |
| Parameter decorators | Not supported | Supported (used for dependency injection) |
emitDecoratorMetadata | Not used | Supported |
| Future | Follows the JavaScript standard | Kept 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
experimentalDecoratorsis 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.
@rangewithout(1, 100000)passes the method torangeasmin, 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
@nameto 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. accessorfields with decorators can transform every read and write; class decorators can register classes.- Legacy
experimentalDecoratorsuse 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
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)
// 50000The 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?
Standard decorators get (value, context); the three-argument form is the legacy style.
2. What is @range(1, 100000)?
A factory is a function that takes configuration and returns a decorator.
3. Which option enables the older decorator style used by many Angular and NestJS projects?
Legacy decorators need "experimentalDecorators": true.
4. Why do decorators fail with Node.js type stripping?
Decorators must be transformed by a compiler until JavaScript engines support them natively.
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
