Introduction to Decorators
A decorator is a function that observes or modifies a class and its members at definition time, using the @expression syntax placed directly above the declaration. They let you attach reusable behavior — logging, validation, dependency injection, ORM mapping — without cluttering the class body. The catch is that TypeScript ships two different decorator systems: the modern Stage 3 decorators standardized by TC39 and supported natively since TypeScript 5.0, and the older legacy decorators gated behind the experimentalDecorators flag. They have incompatible signatures, so the first thing to learn is which one you are using and why.
The two decorator systems
Stage 3 decorators follow the ECMAScript proposal that is on track to become part of the language itself. Legacy decorators implement an older draft and survive mainly because large frameworks — NestJS, Angular, and TypeORM — were built on them and rely on companion features the new standard does not include. Choosing between them is usually dictated by your framework, not personal taste.
| Aspect | Stage 3 (TS 5.0+) | Legacy (experimentalDecorators) |
|---|---|---|
| Flag required | None (default) | experimentalDecorators: true |
| Decorator signature | (value, context) | (target, key, descriptor/index) |
| Parameter decorators | Not supported | Supported |
emitDecoratorMetadata | Not supported | Supported (with reflect-metadata) |
| Used by | New, framework-free code | NestJS, Angular, TypeORM |
| Standardization | Aligned with TC39 proposal | Frozen older draft |
If a single file mixes both styles, TypeScript follows whichever mode the
tsconfig.jsonselects. You cannot use both systems in the same compilation.
Enabling Stage 3 decorators
Stage 3 decorators need no special flag in TypeScript 5.0 or later — just make sure experimentalDecorators is off (the default). Set a reasonable target so the emitted output is clean.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext"
// experimentalDecorators NOT set -> standard decorators
}
}
With this configuration, a decorator receives the decorated value plus a strongly typed context object. The example below logs whenever a method runs.
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This>,
) {
return function (this: This, ...args: Args): Return {
console.log(`Calling ${String(context.name)}`);
return target.call(this, ...args);
};
}
class Greeter {
@logged
greet(name: string) {
return `Hello, ${name}`;
}
}
The context argument carries metadata such as kind, name, and addInitializer. Every Stage 3 decorator has a context parameter — that is the easiest way to recognize the new system at a glance.
Enabling legacy decorators
Frameworks like NestJS still require the legacy system, which you opt into with experimentalDecorators. They almost always pair it with emitDecoratorMetadata plus the reflect-metadata package so the runtime can read constructor parameter types.
{
"compilerOptions": {
"target": "ES2021",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Legacy decorators receive positional arguments instead of a context object. A method decorator, for instance, gets the prototype, the property key, and the property descriptor.
function Log(target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: unknown[]) {
console.log(`Calling ${key}`);
return original.apply(this, args);
};
}
class Service {
@Log
run(task: string) {
return `running ${task}`;
}
}
Notice there is no context parameter and the descriptor is mutated in place. This shape is fundamentally incompatible with Stage 3, so decorators written for one system will not type-check under the other.
Decorator kinds
Both systems can decorate classes, methods, accessors, and properties (fields). Only the legacy system adds parameter decorators, which is the main reason dependency-injection frameworks still depend on it.
// Stage 3: a class decorator that seals the constructor
function sealed(value: Function, context: ClassDecoratorContext) {
Object.seal(value);
Object.seal(value.prototype);
}
@sealed
class Repository {}
The remaining pages in this section cover each kind in detail, always showing the Stage 3 form first and the legacy form where it differs.
Best Practices
- Default to Stage 3 decorators for new code; reach for legacy only when a framework demands it.
- Never combine both systems — pick one per
tsconfig.jsonand keep it consistent. - Enable
emitDecoratorMetadataonly withexperimentalDecorators; it does nothing for Stage 3. - Recognize the system at a glance: a
contextparameter means Stage 3, positional args mean legacy. - Keep decorators small and side-effect-light; prefer returning a wrapper over mutating in place.
- Import
reflect-metadataexactly once at the application entry point when using legacy metadata. - Document which decorator system a shared library targets so consumers configure their compiler correctly.