TypeScript Decorators Done Right: Migrating from Legacy to the TC39 Standard
Most decorator migration failures stem from treating TC39 standard decorators as a drop-in replacement for the legacy experimentalDecorators implementation. Teams flip the compiler flag, watch their codebase explode with type errors, and immediately revert. The reality is that the API changed fundamentally—parameter decorators disappeared, metadata handling shifted entirely, and the decorator signature itself operates on different principles.
This matters because TypeScript 5.0+ ships standard decorators by default, and the legacy implementation will eventually deprecate. Production codebases running experimentalDecorators: true face a migration debt that compounds with every new feature. The transition requires understanding what actually changed at the runtime level, not just syntax adjustments.
Key Takeaways
- Legacy decorators and TC39 standard decorators use incompatible APIs—parameter decorators no longer exist, and metadata handling moved from
reflect-metadatatocontext.metadata. - The migration path requires rewriting decorator functions to accept a
contextobject instead of manipulating descriptors directly, with class decorators now returning replacement values rather than mutating prototypes. - Mixed codebases can run both implementations simultaneously using conditional exports and type-only imports, allowing incremental migration without breaking existing functionality.
- Production teams should migrate when adding new features rather than rewriting working code—the compiler flag strategy enables gradual adoption over multiple releases.
- Dependency injection and validation patterns need complete rewrites because parameter decorators disappeared, shifting metadata collection to class and method decorators with constructor interception.
Legacy vs Standard Decorators: API Differences That Break Your Code
The fundamental breaking change is the decorator signature. Legacy decorators receive different arguments depending on where they're applied—classes get constructors, methods get descriptors, parameters get indices. Standard decorators always receive two arguments: the decorated value and a context object.
Legacy class decorator signature:
function OldDecorator(constructor: Function) {
// Mutate prototype or constructor directly
constructor.prototype.injected = true;
}
Standard decorator signature:
function NewDecorator(target: Function, context: ClassDecoratorContext) {
// Return a replacement value or undefined
context.addInitializer(() => {
console.log('Class initialized');
});
}
The legacy approach allowed direct prototype manipulation. The standard approach requires returning a new constructor or using context.addInitializer for side effects. This distinction is critical—existing decorators that modify target.prototype will not execute in standard mode.
Method decorators show the same pattern shift:
Legacy method decorators returned property descriptors. Standard decorators return functions that replace the original method. The context object provides metadata—method name, visibility, whether it's static—without requiring descriptor manipulation.
Migration Path: Converting experimentalDecorators to TC39 Standard
The migration sequence prevents runtime failures by handling each decorator type separately before flipping the compiler flag. Class decorators convert first because they don't depend on other decorators. Method and accessor decorators follow. Parameter decorators require complete rewrites because they no longer exist.
Start by identifying every decorator in the codebase. Tools like ts-morph or regex searches for @[A-Z] patterns work. Document which decorators manipulate metadata versus behavior—metadata decorators require the most rework.
Convert class decorators by replacing prototype mutations with context.addInitializer:
// Legacy version
function Component(config: { selector: string }) {
return function(constructor: Function) {
constructor.prototype.__selector = config.selector;
};
}
// Standard version
function Component(config: { selector: string }) {
return function<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
context.addInitializer(function() {
(this as any).__selector = config.selector;
});
return target;
};
}
The legacy version modified the prototype immediately. The standard version schedules initialization to run after the class constructs. This timing difference matters for decorators that depend on constructor execution order.
TypeScript decorator migration workflow
Method decorators shift from descriptor manipulation to function wrapping:
// Legacy version
function Trace(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Calling ${key}`);
return original.apply(this, args);
};
}
// Standard version
function Trace<T extends (...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext
) {
return function(this: any, ...args: Parameters<T>): ReturnType<T> {
console.log(`Calling ${String(context.name)}`);
return target.apply(this, args);
};
}
The standard version returns a wrapper function instead of modifying the descriptor. The context object provides the method name without requiring string keys. This approach prevents accidental descriptor overwrites that legacy decorators allowed.
Metadata Without reflect-metadata: The New context.metadata API
The reflect-metadata polyfill powered most legacy decorator metadata systems. Standard decorators eliminate that dependency with context.metadata—a built-in object shared across all decorators on the same class member. This matters because the polyfill added 20-30KB to bundles and required global state that broke in module-isolated environments.
The metadata API operates on a per-decoration-context basis. All decorators on the same method share the same context.metadata object. Class decorators access metadata from all members through context.metadata:
function Validate(rules: Record<string, any>) {
return function(
target: any,
context: ClassFieldDecoratorContext | ClassMethodDecoratorContext
) {
context.metadata[context.name as string] = rules;
};
}
function Controller<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
const metadata = context.metadata;
return class extends target {
validate() {
for (const [key, rules] of Object.entries(metadata)) {
console.log(`Validation rules for ${key}:`, rules);
}
}
};
}
class UserController {
@Validate({ required: true, minLength: 3 })
username!: string;
}
The field decorator stores validation rules in context.metadata. The class decorator reads all accumulated metadata when the class constructs. This pattern replaces the Reflect.getMetadata() calls that legacy decorators required.
The implication here is that metadata lifetime changed. Legacy decorators stored metadata globally through Reflect.defineMetadata. Standard decorators scope metadata to the decoration context, preventing cross-class pollution. This improves type safety but breaks code that expected global metadata lookups.
Parameter Decorators Are Gone: Workarounds and Alternatives
Parameter decorators no longer exist in the TC39 standard. The feature failed to reach consensus because parameter information is available through other mechanisms—Function.length for count, constructor inspection for types. Teams relying on parameter decorators for dependency injection or validation need complete pattern rewrites.
The workaround shifts metadata collection from parameters to the method or constructor level:
Legacy dependency injection:
// Legacy approach - no longer works
class Service {
constructor(@Inject('db') db: Database) {}
}
Standard workaround using class-level metadata:
function Injectable<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
const deps = (target as any).__deps || [];
return class extends target {
constructor(...args: any[]) {
const injected = deps.map((token: string) =>
container.resolve(token)
);
super(...injected);
}
};
}
function Inject(token: string) {
return function(target: any, context: ClassFieldDecoratorContext) {
target.__deps = target.__deps || [];
target.__deps.push(token);
};
}
This pattern stores dependency tokens on the class itself rather than parameter metadata. The class decorator intercepts the constructor to inject dependencies before super() calls. The failure mode here is subtle but expensive—if the decorator order changes or multiple decorators modify __deps, injection can resolve the wrong dependencies.
The alternative approach uses TypeScript's experimental decorator metadata combined with design-time type emission:
function Injectable<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
const paramTypes = Reflect.getMetadata('design:paramtypes', target);
return class extends target {
constructor(...args: any[]) {
const injected = paramTypes.map((type: any) =>
container.resolve(type)
);
super(...injected);
}
};
}
This relies on emitDecoratorMetadata: true in tsconfig, which generates design-time type information. The tradeoff is bundle size—emitted metadata can double decorator overhead—but eliminates manual dependency tracking.
Dependency injection pattern migration
Real-World Migration: Class Validation and Dependency Injection Patterns
Validation decorators demonstrate the migration complexity because they span multiple decorator types and require metadata aggregation. Legacy implementations typically used parameter decorators for constructor validation and method decorators for runtime checks. Standard implementations consolidate everything at the class level.
Legacy validation pattern:
class CreateUserDto {
@IsString()
@MinLength(3)
username!: string;
@IsEmail()
email!: string;
}
The IsString and MinLength decorators stored validation rules via reflect-metadata. A separate validation function retrieved all rules and executed them. Standard decorators require rewriting this as a single class decorator that collects field metadata:
function validate(rules: any) {
return function(target: any, context: ClassFieldDecoratorContext) {
context.metadata[context.name as string] = {
...(context.metadata[context.name as string] || {}),
...rules
};
};
}
function ValidatedClass<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
return class extends target {
constructor(...args: any[]) {
super(...args);
const metadata = context.metadata;
for (const [field, rules] of Object.entries(metadata)) {
const value = (this as any)[field];
if (rules.required && !value) {
throw new Error(`${field} is required`);
}
if (rules.minLength && value.length < rules.minLength) {
throw new Error(`${field} must be at least ${rules.minLength} characters`);
}
}
}
};
}
@ValidatedClass
class CreateUserDto {
@validate({ required: true, minLength: 3 })
username!: string;
@validate({ required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ })
email!: string;
}
The field decorators store rules in context.metadata. The class decorator reads accumulated metadata in the constructor and throws validation errors before the instance fully initializes. This pattern ensures validation happens at construction time rather than requiring manual validation calls.
Dependency injection follows the same metadata accumulation pattern but intercepts the constructor differently:
function Service(token?: string) {
return function<T extends { new(...args: any[]): {} }>(
target: T,
context: ClassDecoratorContext
) {
const serviceToken = token || target.name;
container.register(serviceToken, target);
return class extends target {
constructor(...args: any[]) {
const deps = context.metadata.__deps || [];
const resolved = deps.map((dep: string) => container.resolve(dep));
super(...resolved, ...args);
}
};
};
}
This registers the class in a dependency container and resolves dependencies during construction. The limitation is that constructor parameters must appear in dependency order—manual parameters come after injected ones. This breaks some legacy patterns where dependencies mixed with configuration parameters.
tsconfig.json Setup and Compatibility Strategy for Mixed Codebases
Running both decorator implementations simultaneously requires careful tsconfig configuration. The compiler doesn't support mixing legacy and standard decorators in the same file, but conditional module resolution allows incremental migration.
Base tsconfig.json:
{"compilerOptions":{"target":"ES2022","module":"NodeNext","strict":true,"experimentalDecorators":false}}
Legacy configuration:
{"extends":"./tsconfig.json","compilerOptions":{"experimentalDecorators":true,"emitDecoratorMetadata":true},"include":["src/legacy/**/*"]}
Standard configuration:
{"extends":"./tsconfig.json","include":["src/standard/**/*"]}
This setup isolates legacy decorators to specific directories while new code uses standard decorators. Build tools compile each configuration separately and merge outputs. The tradeoff is build complexity—CI pipelines need to run both compilers and check for cross-boundary imports.
Conditional exports in package.json enable runtime compatibility:
{"exports":{"./decorators":{"legacy":"./dist/legacy/decorators.js","default":"./dist/standard/decorators.js"}}}
Consumers import from the conditional export path. Node.js resolves the correct implementation based on the --conditions flag. This allows libraries to ship both implementations without breaking existing users.
The migration strategy for production apps involves feature flags at the module level. New features use standard decorators. Legacy features remain unchanged until scheduled refactoring. The combination prevents the all-or-nothing migration that causes most migration failures. For more details on TypeScript 6 compatibility concerns, see TypeScript 6 breaking changes.
Frequently Asked Questions
Can I use both experimentalDecorators and standard decorators in the same project?
Yes, but not in the same compilation unit. Use separate tsconfig files with different includes, compile each separately, and merge outputs at build time. This works for gradual migration but adds build complexity and prevents cross-boundary decorator usage.
Do standard decorators work with reflect-metadata for runtime type information?
Standard decorators don't require reflect-metadata for basic metadata storage—use context.metadata instead. However, if you need design-time type information (design:paramtypes), enable emitDecoratorMetadata: true and continue using reflect-metadata. The polyfill still works but isn't necessary for most use cases.
How do I migrate Angular or NestJS decorators to standard decorators?
Don't migrate framework decorators—Angular 19+ and NestJS 11+ ship their own standard decorator implementations. Update framework versions instead of rewriting decorators. Custom application decorators can migrate incrementally using the patterns in this post. For framework-specific guidance, see TypeScript decorators stable real-world use cases.
What happens to existing decorator libraries like class-validator and TypeORM?
Most mature decorator libraries now ship dual implementations—a legacy version and a standard version. Check library documentation for migration guides. Some libraries use conditional exports to serve the correct implementation based on your tsconfig. If a library doesn't support standard decorators yet, you can maintain the legacy implementation in an isolated directory or fork the decorators you need.
Should I migrate if my code works fine with experimentalDecorators?
Migrate when adding new features or refactoring existing modules, not as a standalone project. The legacy implementation will eventually deprecate, but TypeScript maintains backward compatibility for years. Prioritize migration if your bundle size suffers from reflect-metadata overhead or if you need features like isolated metadata scoping that standard decorators provide.
When to Migrate and When to Wait: Decision Framework for Production Apps
The migration decision depends on three factors: bundle size impact, new feature development velocity, and team capacity for API rewrites. Teams shipping client-side applications with decorators see immediate wins from removing reflect-metadata. Server-side applications running Node.js 20+ see negligible performance differences but benefit from future-proofing.
Migrate immediately if you're starting a new project or adding significant new decorator-heavy features. The standard API is cleaner and better documented. Legacy decorator knowledge becomes a liability as the ecosystem standardizes. Migrate incrementally if you have working production code—the compatibility strategy prevents service interruptions while enabling gradual adoption.
Wait if your decorators primarily exist in third-party libraries that haven't migrated yet. Fighting the ecosystem by rewriting framework decorators creates maintenance debt. Wait if your team lacks bandwidth for API rewrites—broken decorators in production are expensive. The legacy implementation works reliably for known patterns.
That covers the essential patterns for migrating from legacy to standard TypeScript decorators. Apply these in production and the difference will be immediate—smaller bundles, better type safety, and a codebase aligned with the language's future direction. For insights into the broader TypeScript 6 ecosystem, see TypeScript 6 final JavaScript release.