Skip to content

TypeScript Decorators: What Changed When I Dropped the Flag

TypeScript Decorators: What Changed When I Dropped the Flag

For about six years, every tsconfig I touched had "experimentalDecorators": true in it. I didn’t think about it. It was load-bearing furniture. NestJS wanted it, TypeORM wanted it, some class-validator setup wanted it, and the flag went in with the same reflex as "strict": true.

Then I started a new project last year, forgot to add the flag, wrote a decorator anyway, and it compiled. That was a confusing five minutes. It turns out TypeScript 5.0 shipped the standard, TC39-aligned decorators, and they work without any flag at all. They’re also a different feature wearing the same syntax, which is a great way to lose an afternoon if nobody tells you.

Short version: if you’re starting fresh, use the standard decorators and delete the flag. If you’re on NestJS or TypeORM, keep the flag, because the old and new versions can’t share a file. The interesting part is what changed in between.

Two features, one syntax

The legacy implementation was TypeScript’s own design, built on a much older version of the TC39 proposal that then changed substantially. The standard implementation follows the current TC39 decorators proposal, which reached stage 3 and is what browsers and other runtimes will eventually implement natively.

That matters more than it sounds. Legacy decorators were a TypeScript-only dialect. Standard decorators are JavaScript that TypeScript happens to type check. Anything you write against the new API has a future outside the TypeScript compiler.

The flag switches which implementation you get. There’s no mixed mode. If experimentalDecorators is on, you get the old behaviour everywhere, and none of what follows applies to your codebase.

What a decorator actually receives now

Here’s a method decorator the old way. Three positional arguments, a mutable property descriptor, and a lot of trust that you know what target is:

function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`calling ${key}`);
    return original.apply(this, args);
  };
  return descriptor;
}

The signature never told you much. target is the prototype for instance methods and the constructor for statics, which you had to remember rather than read. Mutating descriptor.value in place and also returning it is belt and braces. And key is a string in the type signature even though it can be a symbol.

Here’s the same thing with standard decorators:

function log<T extends (...args: any[]) => any>(
  original: T,
  context: ClassMethodDecoratorContext
): T {
  return function (this: unknown, ...args: any[]) {
    console.log(`calling ${String(context.name)}`);
    return original.call(this, ...args);
  } as T;
}

class Billing {
  @log
  charge(amount: number) {
    return amount * 100;
  }
}

Two arguments. The first is the thing being decorated, which for a method is the function itself rather than a descriptor wrapping it. The second is a context object that tells you what you’re looking at: kind ("method", "field", "getter", "setter", "class", "accessor"), name, static, private, plus a couple of functions I’ll get to.

You return a replacement or you return nothing. No descriptor mutation. The narrower context types are the part I appreciate most day to day, because ClassMethodDecoratorContext and ClassFieldDecoratorContext are different types and the compiler will tell you when you’ve applied a decorator to the wrong kind of member. Under the legacy implementation that was a runtime surprise.

The initializer hook that replaced my constructor hacks

context.addInitializer is the piece I didn’t expect to use as much as I do. It registers a callback that runs during construction, with this bound to the instance.

The classic use is auto-binding methods so you can pass them around as callbacks without losing this:

function bound(
  original: Function,
  context: ClassMethodDecoratorContext
) {
  context.addInitializer(function (this: any) {
    this[context.name] = original.bind(this);
  });
}

class Toolbar {
  label = "Save";

  @bound
  onClick() {
    console.log(this.label);
  }
}

const t = new Toolbar();
const handler = t.onClick;
handler(); // logs "Save" instead of exploding

Before this existed I was doing the same job with arrow function class properties, which works but allocates a fresh closure per instance per method and doesn’t live on the prototype. It also can’t be overridden by a subclass in the way people expect. The decorator version is more honest about what it’s doing.

Registration is the other good use. Rather than maintaining an array of route handlers by hand, an initializer can push into a registry as each instance builds itself. That’s most of what a decorator-based framework does under the hood, and it’s now about eight lines instead of a metadata reflection dance.

Metadata, and the thing that actually broke

The old ecosystem leans hard on reflect-metadata. Every DI container I’ve used stores constructor parameter types in a metadata side table, reads them back at resolution time, and hands you an instance. That depends on emitDecoratorMetadata, which only works with the legacy flag on.

Standard decorators have their own metadata story via context.metadata and Symbol.metadata, added in TypeScript 5.2 and described in the TypeScript 5.0 release notes and the releases that followed. It’s a plain object shared across decorators on the same class, and you read it off the constructor afterwards:

function tag(value: string) {
  return function (_target: unknown, context: ClassMethodDecoratorContext) {
    (context.metadata as any)[context.name] = value;
  };
}

What it does not give you is parameter types. There is no standard equivalent of emitDecoratorMetadata emitting design-time type information, because that was always a TypeScript-specific emit and the standard proposal is deliberately runtime-only. So constructor-injection containers that infer dependencies from types can’t be ported straight across. They need explicit tokens instead.

This is why NestJS and TypeORM still ask for the legacy flag, and it isn’t laziness on their part. It’s a genuine capability gap. I spent a while assuming I’d missed a migration guide before working out there wasn’t one to miss.

Where I don’t use decorators at all

Validation. I used to decorate every DTO field and I’ve stopped. Schema-first libraries give me a parsed, typed value and a runtime error I can serialise, without the class ever needing to exist. Decorators tie validation to a class shape, and half the time the thing I’m validating arrived as JSON and wants to stay a plain object.

Anything where a plain higher order function does the job. withRetry(fn) reads more clearly than @retry on a method, it’s easier to test in isolation, and nobody has to check the tsconfig to understand it. I reach for a decorator when the behaviour genuinely belongs to the class member and needs to know its own name, and otherwise I don’t.

Public library APIs, mostly. Asking consumers to configure a compiler feature so your package works is a real cost, and it’s a cost that gets worse for anyone bundling for the browser.

The general pattern here matches what I found writing about the type casts I stopped writing with satisfies: the newer feature is usually narrower and more boring than the thing it replaced, and that’s the point.

Migrating without breaking everything

The migration path is per project, not per file, which is the awkward bit. You can’t flip one module over and leave the rest. So the realistic options are: start new projects on standard decorators, and leave existing framework-heavy projects on the flag until the framework moves.

For the code I actually control, the port was mechanical. Method decorators lose the descriptor and gain a context object. Field decorators change the most, because the old ones ran against the prototype and the new ones return an initializer function that transforms the field’s starting value:

function upper(_target: undefined, _context: ClassFieldDecoratorContext) {
  return function (initial: string) {
    return initial.toUpperCase();
  };
}

class User {
  @upper name = "qasim";
}

That return-a-function shape confused me for longer than I’d like to admit. The decorator itself runs once at class definition time; the function it returns runs per instance, per field. Once you see it as two phases it stops being weird.

Class decorators are the closest to unchanged. You get the constructor and a context, and returning a new constructor still replaces the class. The TypeScript 5.2 release notes cover the metadata addition specifically, which is worth reading if you maintain anything that stores per-member configuration.

One practical warning: your bundler and your test runner both need to agree with your compiler about which decorator flavour is in play. I had a build that passed and a test suite that didn’t, and the answer was an esbuild target setting nobody had touched in two years.

What to try this week

Open your tsconfig and check whether experimentalDecorators is actually needed. If nothing in your dependency tree requires it, delete the line, run the build, and see what falls over. On a small codebase that’s a ten minute experiment with a clear answer.

If it stays, write down why. A one-line comment saying which package needs it saves the next person the archaeology I did.

And if you’ve been auto-binding methods with arrow class properties, try the bound decorator above on one class. It’s a small change with a visible payoff, and it’s a low-risk way to get a feel for the new API before you rewrite anything that matters. I do a fair amount of this kind of incremental TypeScript modernisation on client codebases, and there’s more of that in my work.