Skip to main content
Zubin Khavarian
Watercolor of two same-sized wrapped parcels with incompatible ornaments, one brass ribbon and round seal, the other dusty-blue ribbon and square clasp.

TypeScript has two decorator systems

· Last updated

TypeScript has two decorator systems. They share an @ and almost nothing else.

I used to treat decorators as “that Angular thing you flip on with experimentalDecorators.” That was accurate in 2015. It is a trap now.

TypeScript 5.0 🔗 shipped the Stage 3 ECMAScript proposal 🔗 as default syntax. No flag. Different arguments. Different emit. The old flag still exists, and it switches you back to the 2015 design.

Leave the flag off unless a framework forces it on.

What is a decorator?

A decorator is a function. You attach it to a class, or to a member of a class, with @.

It is not a comment. It runs when the class is defined. It can wrap the method, replace it, or register some setup to run later.

You cannot decorate a free function. You cannot decorate a parameter, not in the standard system. If you need either of those, you do not want standard decorators. You want the old flag, or you want a different pattern.

How do standard decorators look?

Two arguments. The value being decorated, and a context object.

This is 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. It returns a new function. That new function is greet from then on.

ClassMethodDecoratorContext is a built-in type. It tells you the member name, whether it is static, whether it is #private. You do not poke target.prototype yourself.

A decorator factory is a function that returns a decorator. Same as before, just 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. @a @b method means b runs first, then a wraps the result. Reverse order. Easy to forget.

Do I need a tsconfig flag?

No.

If experimentalDecorators is missing or false, you get the standard system. TypeScript will emit a transform until engines implement this natively. The proposal is still Stage 3. Stage 4 needs two independent browser implementations. Do not paste @logged into a raw <script> tag and expect every browser to run it.

If experimentalDecorators is true, you get the 2015 system for the whole project. One tsconfig, one system. You cannot 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. Mutate the descriptor. target is the prototype or the constructor. Reflect.metadata and emitDecoratorMetadata live here. So do parameter decorators, the @Inject() you put on a constructor argument.

None of that exists on the standard path. TypeScript’s 5.0 notes 🔗 say so directly. The new proposal is not compatible with emitDecoratorMetadata. It does not allow decorating parameters.

That is why Nest still wants the old flag. @Inject() on a constructor argument is a parameter decorator. Flip experimentalDecorators off in a Nest app and the framework’s wiring evaporates.

Angular has been moving. Treat @Component as the framework’s problem. If your tsconfig still has experimentalDecorators: true, you are on the old system whether you meant to be or not.

Existing decorator functions almost never work on both systems. The TypeScript team said that too. Don’t try to write one function that handles (value, context) and (target, key, descriptor).

What’s the catch?

Standard decorators are for classes. Logging and wrapping. They are a sharp tool for that.

They are a bad way to do dependency injection, because DI in TypeScript-land grew up on parameter decorators and emitted type metadata. That stack is still the old flag.

accessor fields are new. accessor count = 0 is a field with generated get/set, and you can decorate it. Skip it until you have a reason.

Types on a well-written decorator get loud. This, Args, Return. The 5.0 notes show a fully generic loggedMethod if you want it. For application code, start with ClassMethodDecoratorContext and tighten when the compiler asks.

What should you not do?

Turn on experimentalDecorators because a blog post from 2018 said to. That blog post, including an earlier version of this one, was teaching the old API.

Copy a (target, key, descriptor) snippet into a project that does not have the flag. It will typecheck against the wrong system, or it will fail in a way that looks like your class is wrong.

Decorate a parameter and expect it to work without the flag. It will not.

I’d start with the standard system and no flag. Keep experimentalDecorators for the framework that still needs parameter decorators. Don’t 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
Zubin Khavarian, Principal Front-End EngineerWritten by