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.
- You've seen
Reflect.getMetadata('design:paramtypes', ...)returnundefinedand 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
experimentalDecoratorsin an existing app and half the codebase stopped compiling - You're not sure whether
emitDecoratorMetadatais 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.metadatais 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.

