TypeScript Has Two Decorator Systems
· Last updated · 4 min read · #javascript #typescript

TypeScript has two decorator systems. They share an @ sign and almost nothing else.
I used to think of decorators as “that Angular thing you switch on with experimentalDecorators.” That was accurate in 2015. Today it’s a trap.
TypeScript 5.0 (opens in a new tab) shipped the ECMAScript decorators proposal (opens in a new tab) as the default, with no flag, different arguments and different output. The old flag still exists, and turning it on sends you back to the 2015 design.
So leave the flag off unless a framework forces it on.
What is a decorator?
A decorator is a function you attach to a class, or to a member of a class, with @.
It isn’t a comment or an annotation. It runs when the class is defined, and it can wrap the method, replace it, or register some setup to run later.
You can’t decorate a free-standing function, and in the standard system you can’t decorate a parameter either. If you need either of those, standard decorators aren’t what you want; you want the old flag, or a different pattern entirely.
How do standard decorators look?
They take two arguments: the value being decorated, and a context object describing it. Here’s the method-decorator example from the TypeScript 5.0 notes, tightened a little:
function logged(originalMethod: Function, context: ClassMethodDecoratorContext) {
const name = String(context.name);
function replacement(this: unknown, ...args: unknown[]) {
console.log(`entering ${name}`);
const result = originalMethod.call(this, ...args);
console.log(`exiting ${name}`);
return result;
}
return replacement;
}
class Person {
constructor(readonly name: string) {}
@logged
greet() {
console.log(`hello, ${this.name}`);
}
}
logged receives greet and returns a new function, and from then on that new function is greet.
ClassMethodDecoratorContext is a built-in type that tells you the member’s name and whether it’s static or #private. You don’t go poking at target.prototype yourself anymore.
A decorator factory is still just a function that returns a decorator, with the new signature on the inner function:
function logged(prefix = "LOG") {
return function (originalMethod: Function, context: ClassMethodDecoratorContext) {
const name = String(context.name);
return function (this: unknown, ...args: unknown[]) {
console.log(`${prefix} ${name}`);
return originalMethod.call(this, ...args);
};
};
}
class Person {
@logged(">>")
greet() {
console.log("hello");
}
}
Stacking works too, in an order that’s easy to forget. With @a @b method, b wraps the method first and then a wraps the result:
Do I need a tsconfig flag?
No.
If experimentalDecorators is missing or false, you get the standard system. TypeScript compiles decorators down to plain JavaScript until engines support them natively, so don’t paste @logged into a raw <script> tag and expect every browser to run it yet.
If experimentalDecorators is true, you get the 2015 system for the whole project. It’s one system per tsconfig; you can’t mix them file by file.
What about the old decorators?
They look like this:
function enumerable(value: boolean) {
return function (target: object, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.enumerable = value;
};
}
Three arguments, and you mutate the property descriptor. target is either the prototype or the constructor. This is also where Reflect.metadata and emitDecoratorMetadata live, along with parameter decorators, like the @Inject() you put on a constructor argument.
None of that exists in the standard system. TypeScript’s 5.0 notes (opens in a new tab) say so directly: the new decorators aren’t compatible with emitDecoratorMetadata, and they don’t allow decorating parameters.
That’s why NestJS still wants the old flag. @Inject() on a constructor argument is a parameter decorator, so if you switch experimentalDecorators off in a Nest app, the framework’s wiring simply evaporates.
Angular has been moving in its own direction; treat @Component as the framework’s business. And whatever framework you use, if your tsconfig still says experimentalDecorators: true, you’re on the old system whether you meant to be or not.
Existing decorator functions almost never work under both systems, and the TypeScript team says as much. Don’t try to write one function that handles both (value, context) and (target, key, descriptor).
What’s the catch?
Standard decorators are built for classes: logging, wrapping, registering. They’re a sharp tool for that job.
They’re a poor fit for dependency injection, though, because DI in TypeScript grew up on parameter decorators and emitted type metadata, and that whole stack still lives behind the old flag.
accessor fields are new, too. accessor count = 0 is a field with a generated getter and setter that you can decorate. I’d skip it until you have a concrete reason.
And the types on a carefully written decorator get noisy fast, with This, Args and Return type parameters. The 5.0 notes show a fully generic loggedMethod if you want one. For application code, start with ClassMethodDecoratorContext and tighten things up when the compiler asks you to.
What should you not do?
Turn on experimentalDecorators because a blog post from 2018 said to. That post, including an earlier version of this one, was teaching the old API.
Copy a (target, key, descriptor) snippet into a project without the flag. It’ll typecheck against the wrong system, or fail in a way that makes it look like your class is broken.
Decorate a parameter and expect it to work without the flag. It won’t.
Start with the standard system and no flag. Keep experimentalDecorators for the frameworks that still need parameter decorators, and don’t try to invent a third option.
Stay in touch
Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.
Follow @zkmake