TypeScript Decorators Done Right: Migrating from Legacy to the TC39 Standard
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 context object 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.
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.
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, and the build system resolves the correct implementation based on the consuming project's decorator mode.
Comments
No comments yet. Start the discussion.