In simple language, a decorator is a function that wraps a class, method, accessor, or property to modify its behavior. The syntax is @decorator placed above the target. Think of it like a higher-order function but applied at definition time — like Python decorators if we’ve used those.
There are two decorator systems in TS, and that’s the main source of confusion. The legacy/experimental one (with experimentalDecorators: true) is what frameworks like NestJS and TypeORM still use. The new standard (TC39 stage 3, native in TS 5.0+) is the future.
Why we want them
Decorators shine for cross-cutting concerns — things we want to bolt onto many places without repeating ourselves. Logging, caching, validation, dependency injection.
class UserService {
@log
@cache(60)
async getUser(id: string) {
return await db.users.find(id);
}
}
Two annotations, and getUser is now logged on every call and cached for 60 seconds. No wrapper functions, no manual instrumentation.
The new (TC39 stage 3) decorators
This is the version we should learn first — it’s the standard going forward. A decorator is a function that receives the target and a context object, and returns a replacement (or undefined).
function log<T extends (...args: any[]) => any>(
originalMethod: T,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
console.log(`calling ${String(context.name)}`);
const result = originalMethod.apply(this, args);
console.log(`returned`, result);
return result;
} as T;
}
class Greeter {
@log
greet(name: string) {
return `Hello, ${name}`;
}
}
new Greeter().greet("Manish");
// calling greet
// returned Hello, Manish
The context object tells us what kind of thing we’re decorating ("method", "class", "field", "getter", "setter", "accessor") and gives us hooks like addInitializer. No more reflection metadata gymnastics.
Where can decorators sit?
@deco class Foo { }
class decorator
@deco method() { }
method decorator
@deco accessor name = "x";
auto-accessor
@deco get prop() { }
getter/setter
@deco field = 1;
field (stage 3 only)
Decorator factories — passing arguments
The bare @log form doesn’t take arguments. To pass options, we write a factory — a function that returns a decorator.
function retry(attempts: number) {
return function <T extends (...args: any[]) => any>(
original: T,
_context: ClassMethodDecoratorContext
) {
return async function (this: any, ...args: any[]) {
for (let i = 0; i < attempts; i++) {
try { return await original.apply(this, args); }
catch (e) { if (i === attempts - 1) throw e; }
}
} as T;
};
}
class API {
@retry(3)
async fetch(url: string) {
return await window.fetch(url);
}
}
@retry(3) calls the factory with 3, which returns the actual decorator. This is the most common shape in real code.
The legacy (experimental) decorators
Most TS code in the wild — Angular, NestJS, TypeORM, MikroORM — still uses the experimental form. It needs two tsconfig flags.
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
The signature is different. A legacy method decorator takes (target, propertyKey, descriptor):
function logLegacy(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`calling ${key}`);
return original.apply(this, args);
};
}
class OldGreeter {
@logLegacy
greet(name: string) { return `Hi, ${name}`; }
}
emitDecoratorMetadata makes TS emit type info at runtime (via reflect-metadata), which frameworks like NestJS rely on for dependency injection.
Picking which one to use
Right now (TS 5.x), here’s the practical guidance:
- Greenfield project, no framework requiring legacy → use stage 3 (default, no flag needed)
- NestJS / Angular / TypeORM → must use legacy +
emitDecoratorMetadata
- Library author → avoid decorators in the public API if possible; if needed, target stage 3
The two are not interoperable in the same file. We pick one for the project and stick with it.
A practical example — dependency injection
Decorators feel abstract until we see a real-world payoff. Here’s a tiny DI container using the stage 3 API.
const services = new Map<string, any>();
function injectable(name: string) {
return function <T extends new (...args: any[]) => any>(target: T, _ctx: ClassDecoratorContext) {
services.set(name, new target());
return target;
};
}
@injectable("logger")
class Logger {
info(msg: string) { console.log(`[INFO] ${msg}`); }
}
const log = services.get("logger") as Logger;
log.info("hello");
Decorators do the boring registration work so our classes stay clean. That’s the whole pitch.