Two Decorators, One Keyword, Zero Compatibility

Copy a @Injectable() pattern from a NestJS tutorial into a fresh TypeScript 5.4 project with no experimentalDecorators flag set, and it will compile. It will also do something completely different at runtime than the tutorial author intended, because @ now means two unrelated things depending on one tsconfig setting. Legacy decorators (behind experimentalDecorators) and the TC39 Stage 3 decorators TypeScript 5.0 shipped by default are separate proposals with separate call signatures. They are not two syntaxes for the same feature. Treating them that way is how people end up debugging a decorator that silently receives undefined where they expected a property descriptor.

๐Ÿค” Sound familiar?
  • You've seen Reflect.getMetadata('design:paramtypes', ...) return undefined and had no idea why
  • NestJS or TypeORM examples use decorators that look nothing like the ones in the TypeScript 5 release notes
  • You tried turning off experimentalDecorators in an existing app and half the codebase stopped compiling
  • You're not sure whether emitDecoratorMetadata is doing anything in your current build

Neither proposal is going away soon โ€” legacy decorators still power most dependency-injection frameworks in production. Knowing which one you're looking at, and why they can't be mixed, saves a specific kind of confusing afternoon.

Legacy: experimentalDecorators

This is the decorator proposal from 2018, stabilized long before TC39 settled on anything, and it's what Angular, NestJS, and TypeORM are built on. A method decorator receives the target, the property key, and a mutable property descriptor โ€” the same three argumentsObject.defineProperty would take.

// tsconfig.json: "experimentalDecorators": true, "emitDecoratorMetadata": true

function LegacyLogged(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (this: unknown, ...args: unknown[]) {
    console.log(`โ†’ ${propertyKey}(${args.map(String).join(', ')})`);
    return original.apply(this, args);
  };
  return descriptor;
}

class OrderServiceLegacy {
  @LegacyLogged
  createOrder(customerId: string) {
    return { id: crypto.randomUUID(), customerId };
  }
}

Native: TC39 Stage 3 (TypeScript 5.0+)

No flag required โ€” this is the default the moment experimentalDecorators is absent or false. The signature changes shape entirely: a method decorator gets the original function and a context object, and returns a replacement function instead of mutating a descriptor in place.

function logged(originalMethod: any, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`โ†’ ${methodName}(${args.map(String).join(', ')})`);
    return originalMethod.call(this, ...args);
  };
}

class OrderService {
  @logged
  createOrder(customerId: string) {
    return { id: crypto.randomUUID(), customerId };
  }
}

Same intent, same decorator name pattern, completely incompatible internals. Paste the legacy version into a project without experimentalDecoratorsand TypeScript rejects it โ€” the descriptor-mutation signature doesn't satisfy what the new proposal expects a decorator to look like.


flowchart LR
    D["@decorator on a class"] --> F{"experimentalDecorators<br/>in tsconfig?"}
    F -- true --> L["Legacy proposal<br/>(target, key, descriptor)"]
    F -- false / unset --> N["TC39 Stage 3<br/>(value, context)"]
    L --> LM["emitDecoratorMetadata + reflect-metadata<br/>= automatic design:type reflection"]
    N --> NM["context.metadata + Symbol.metadata<br/>= manual metadata bag, no auto reflection"]

    style L fill:#dc2626,color:#fff,stroke:#b91c1c
    style N fill:#16a34a,color:#fff,stroke:#15803d

Field, Accessor, and Class Decorators Under the New Proposal

The context object's kind tells you what you're decorating โ€” "class", "method", "getter", "setter", "field", or "accessor". Field decorators are the odd one out: they run before the instance exists, so the value you receive is undefined, not the field's initial value. What you return is an initializer function TypeScript calls with the real initial value once the instance is being constructed.

function validate(_value: undefined, context: ClassFieldDecoratorContext) {
  return function (this: unknown, initialValue: number) {
    if (initialValue < 0) {
      throw new RangeError(`${String(context.name)} cannot be negative`);
    }
    return initialValue;
  };
}

class Inventory {
  @validate
  quantity = 0;
}

addInitializer is the other piece worth knowing โ€” it hooks into construction without needing to touch the field or method directly, which is how a @bound decorator can rebind this for every instance without a constructor override:

function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
  const methodName = context.name;
  if (context.private) {
    throw new Error(`'bound' cannot decorate private members like ${String(methodName)}`);
  }
  context.addInitializer(function (this: any) {
    this[methodName] = this[methodName].bind(this);
  });
}

Where the Metadata Went

Legacy decorators, combined with emitDecoratorMetadata and the reflect-metadatapolyfill, get something the new proposal deliberately doesn't offer: automatic type reflection. TypeScript emits design:paramtypes for every constructor, and a DI container reads it to figure out what a class needs without you ever writing a token.

import 'reflect-metadata';

@Injectable()
class UserService {
  constructor(private db: DatabaseConnection) {}
  // Nest reads Reflect.getMetadata('design:paramtypes', UserService) here โ€”
  // it's type-driven, and you never named 'DatabaseConnection' as a string anywhere
}

The TC39 proposal replaced that with context.metadata, a plain object each decorator can write into, exposed on the class via Symbol.metadata. It's a bag you fill in yourself โ€” nothing inspects your constructor's parameter types for you.

function inject(token: string) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    context.metadata[context.name] = token; // you supply the token โ€” no type inference happening
  };
}

class UserServiceNew {
  @inject('DB_CONNECTION')
  private db!: DatabaseConnection;
}

const meta = UserServiceNew[Symbol.metadata]; // { db: 'DB_CONNECTION' }

That's the actual reason NestJS, TypeORM, and Angular haven't migrated their decorator internals. It isn't inertia. Their entire dependency-injection model is built on TypeScript telling the container what type a constructor parameter expects, automatically, and TC39 deliberately declined to standardize that โ€” baking a runtime reflection system into the language spec was never on the table. Replicating it means every injected dependency needs an explicit string or symbol token, which is a breaking API change for every consumer of those frameworks, not a quiet internal refactor.

When to Use Which

If you're working in an existing NestJS, TypeORM, or Angular codebase, use legacy decorators โ€” the framework was compiled against that proposal and won't function correctly under the other one. New code with no dependency on those frameworks' reflection-based DI is the place to reach for native decorators: they're standard JavaScript syntax now, not a TypeScript-only compile step, which matters if this code ever needs to run through Babel or ship to a runtime without a TypeScript-aware build.

Pitfalls

Native decorators don't support parameter decorators at all โ€” no constructor(@Inject('X') private db: Db). There's no equivalent syntax position in the TC39 proposal; parameter-level decoration simply isn't part of it. That alone blocks a mechanical migration for any codebase doing constructor injection this way, which is most of them.

And emitDecoratorMetadatadoesn't error under native decorators โ€” it just does nothing. Copy a tsconfig from an older project, leave the flag in, and you won't find out it's inert until a DI container silently fails to resolve a dependency it used to resolve fine.

Key Takeaways

  • Legacy and native decorators are different proposals with different call signatures โ€” code written for one silently misbehaves under the other, it doesn't just warn and move on.
  • reflect-metadata's automatic parameter-type reflection has no native-decorator equivalent; context.metadata is a bag you fill yourself, which is exactly why DI-heavy frameworks are staying on legacy.
  • Native decorators drop parameter decorators entirely โ€” a hard blocker for constructor-injection-heavy code, not a minor syntax change.
  • Stick with legacy for NestJS, TypeORM, and Angular; reach for native decorators in new code that doesn't depend on those frameworks' reflection.