TypeScript 5.8+ Stage 3 Decorators: Production Metaprogramming, DI & Runtime Validation

Table of Contents(8 sections)
TypeScript 5.8+ natively supports ECMAScript Stage 3 Decorators, marking a pivotal shift from the legacy experimentalDecorators flag. This native implementation provides a robust, standardized mechanism for metaprogramming, enabling powerful patterns like Dependency Injection (DI) and runtime validation without relying on non-standard features or polyfills. This guide details the practical application of these decorators, focusing on their architecture, implementation, and production utility.
Understanding Stage 3 Decorators
Stage 3 Decorators operate on class elements during definition, not instantiation. They are functions that receive specific contexts and allow modification or replacement of the decorated element. Unlike experimentalDecorators, the new specification provides a more predictable and powerful API, including access to private fields and the ability to add initializers.
Decorator Types and Signatures
The decorator function signature varies based on the target:
-
Class Decorators: Applied to class declarations.
typescripttype ClassDecorator = <T extends Function>( value: T, context: ClassDecoratorContext ) => T | void;contextprovideskind: 'class',name, andaddInitializer. -
Method Decorators: Applied to methods within a class.
typescripttype MethodDecorator = <T extends Function>( value: T, context: ClassMethodDecoratorContext ) => T | void;contextprovideskind: 'method',name,static,private,access, andaddInitializer. -
Getter/Setter Decorators: Applied to accessors.
typescripttype AccessorDecorator = <T>( value: ClassAccessorDecoratorTarget<T>, context: ClassAccessorDecoratorContext<ThisType<T>, T> ) => ClassAccessorDecoratorResult<ThisType<T>, T> | void;contextprovideskind: 'getter'or'setter',name,static,private,access, andaddInitializer. -
Field Decorators: Applied to class fields.
typescripttype FieldDecorator = <T>( value: undefined, // Field decorators receive undefined as value context: ClassFieldDecoratorContext<ThisType<T>, T> ) => ((initialValue: T) => T) | void;contextprovideskind: 'field',name,static,private,access, andaddInitializer. The return value is a function that modifies the field's initial value. -
Auto-Accessor Decorators: Applied to
accessorproperties.typescripttype AutoAccessorDecorator = <T>( value: ClassAccessorDecoratorTarget<T>, context: ClassAccessorDecoratorContext<ThisType<T>, T> ) => ClassAccessorDecoratorResult<ThisType<T>, T> | void;Similar to getter/setter, but for the combined
accessorsyntax.
Configuration
Ensure tsconfig.json is configured for native decorators:
{
"compilerOptions": {
"target": "ES2022", // Or higher
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Bundler", // Or Node16/NodeNext
"experimentalDecorators": false, // Crucial: Disable legacy decorators
"emitDecoratorMetadata": false, // Crucial: Disable legacy metadata
"useDefineForClassFields": true // Recommended for class fields
}
}
Use Case 1: Lightweight Dependency Injection Container
A zero-dependency DI container demonstrates class and field decorators for managing service lifecycles and injecting dependencies.
// di.ts
// A simple map to store registered services and their instances
const serviceRegistry = new Map<string | symbol, { constructor: new (...args: any[]) => any; instance?: any; singleton: boolean }>();
// Symbol for internal metadata storage on classes
const INJECTABLE_METADATA = Symbol('injectable_metadata');
/**
* Class decorator to mark a class as injectable and register it with the DI container.
* @param singleton If true, only one instance of the class will be created and reused.
*/
export function Injectable(singleton: boolean = true) {
return <T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext<T>
) => {
if (context.kind !== 'class') {
throw new Error(`@Injectable can only be applied to classes, not ${String(context.kind)}`);
}
const serviceName = target.name;
if (serviceRegistry.has(serviceName)) {
console.warn(`Service "${serviceName}" already registered. Overwriting.`);
}
serviceRegistry.set(serviceName, { constructor: target, singleton });
// Store metadata about dependencies for this class
context.addInitializer(function(this: T) {
// This runs after the class is defined, but before any instances are created.
// We can use 'this' to refer to the class constructor itself.
// Any @Inject() decorators would have added metadata to this.
});
return target; // Return the original class
};
}
/**
* Property decorator to inject a dependency into a class field.
* The field type annotation is used to determine the dependency to inject.
*/
export function Inject(targetService?: new (...args: any[]) => any) {
return <T, V>(
value: undefined, // Field decorators receive undefined as value
context: ClassFieldDecoratorContext<T, V>
) => {
if (context.kind !== 'field') {
throw new Error(`@Inject can only be applied to fields, not ${String(context.kind)}`);
}
// This initializer runs when an instance of the class is created.
context.addInitializer(function(this: T) {
// 'this' refers to the instance of the class being constructed.
// We need to determine the type of the field to inject.
// In TypeScript, runtime type information for fields is not directly available
// without emitDecoratorMetadata (which we're avoiding) or explicit type passing.
// For simplicity, we'll rely on `targetService` if provided, or assume the field name
// corresponds to a registered service name. A more robust solution might use a symbol
// or string literal for the service key.
const fieldName = String(context.name);
let serviceKey: string | symbol;
if (targetService) {
serviceKey = targetService.name;
} else {
// Fallback: assume field name is the service name.
// This is less robust and prone to naming conflicts.
// A better approach would be to require @Inject(ServiceClass)
serviceKey = fieldName.charAt(0).toUpperCase() + fieldName.slice(1); // e.g., 'logger' -> 'Logger'
console.warn(`@Inject on field '${fieldName}' without explicit service. Assuming service name '${String(serviceKey)}'. Consider @Inject(ServiceClass).`);
}
const serviceEntry = serviceRegistry.get(serviceKey);
if (!serviceEntry) {
throw new Error(`Service "${String(serviceKey)}" not found in DI container for injection into field "${fieldName}".`);
}
if (serviceEntry.singleton && serviceEntry.instance) {
// Reuse existing singleton instance
(this as any)[fieldName] = serviceEntry.instance;
} else {
// Create new instance, resolving its dependencies recursively
const instance = resolve(serviceEntry.constructor);
if (serviceEntry.singleton) {
serviceEntry.instance = instance; // Store for future reuse
}
(this as any)[fieldName] = instance;
}
});
// Field decorators return a function to modify the initial value.
// Since we're injecting, we don't need an initial value from the decorator.
return;
};
}
/**
* Resolves an instance of a service from the DI container.
* Handles recursive dependency resolution.
*/
export function resolve<T>(constructor: new (...args: any[]) => T): T {
const serviceName = constructor.name;
const serviceEntry = serviceRegistry.get(serviceName);
if (!serviceEntry) {
throw new Error(`Service "${serviceName}" not registered with DI container.`);
}
if (serviceEntry.singleton && serviceEntry.instance) {
return serviceEntry.instance as T;
}
// Recursively resolve constructor arguments
// This is a simplified approach. A real DI container would inspect constructor
// parameters for @Inject annotations or type metadata.
// For this example, we assume constructor args are also Injectable services
// or primitive types not managed by DI.
const dependencies: any[] = []; // In a real system, this would be populated by inspecting constructor params
const instance = new constructor(...dependencies);
// The @Inject field decorators will run their initializers *after* the constructor completes
// and before the instance is fully returned from `new constructor()`.
// This is handled by the JS engine's class construction process.
if (serviceEntry.singleton) {
serviceEntry.instance = instance;
}
return instance;
}
// --- Usage Example ---
interface ILogger {
log(message: string): void;
}
@Injectable(true)
class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(`[ConsoleLogger] ${message}`);
}
}
@Injectable(false) // Not a singleton
class TransientService {
private id: number;
constructor() {
this.id = Math.random();
console.log(`TransientService instance created: ${this.id}`);
}
getId(): number {
return this.id;
}
}
@Injectable(true)
class UserService {
@Inject(ConsoleLogger) // Explicitly inject ConsoleLogger
private logger!: ILogger; // Use definite assignment assertion
@Inject() // Implicitly inject TransientService (by field name convention)
private transientService!: TransientService;
getUser(id: string): string {
this.logger.log(`Fetching user ${id}`);
return `User ${id} (Transient ID: ${this.transientService.getId()})`;
}
}
// Resolve the root service
const userService = resolve(UserService);
console.log(userService.getUser('123'));
const anotherUserService = resolve(UserService);
console.log(anotherUserService.getUser('456'));
// Verify singleton behavior for UserService and ConsoleLogger
console.log('userService === anotherUserService:', userService === anotherUserService); // Should be true
// Verify transient behavior for TransientService
// The transientService injected into userService and anotherUserService should be different instances
// This is because @Inject() creates a new instance for each injection point if the service is not a singleton.
// However, since UserService itself is a singleton, the same TransientService instance will be injected
// into the *same* UserService instance. To see different TransientService instances, we'd need to
// resolve UserService multiple times if it were *not* a singleton, or inject TransientService into
// different non-singleton services.
// Let's demonstrate by resolving TransientService directly:
const ts1 = resolve(TransientService);
const ts2 = resolve(TransientService);
console.log('ts1 === ts2:', ts1 === ts2); // Should be false, as TransientService is not a singleton
// Expected output:
// TransientService instance created: 0.xxxx
// TransientService instance created: 0.yyyy
// [ConsoleLogger] Fetching user 123
// User 123 (Transient ID: 0.xxxx)
// [ConsoleLogger] Fetching user 456
// User 456 (Transient ID: 0.xxxx)
// userService === anotherUserService: true
// TransientService instance created: 0.zzzz
// TransientService instance created: 0.aaaa
// ts1 === ts2: false
This DI container uses @Injectable to register classes and @Inject to mark fields for dependency injection. The addInitializer callback within the field decorator is crucial: it runs after the class constructor but before the instance is fully returned, allowing the field to be populated with the resolved dependency.
Use Case 2: High-Throughput Runtime Schema Validation
Runtime validation is critical for API boundaries and data integrity. Decorators can automate schema application and validation. We'll use a simplified validation library.
// validator.ts
type ValidatorFn = (value: any) => string | null; // Returns error message or null if valid
const VALIDATION_METADATA = Symbol('validation_metadata');
interface FieldValidation {
propertyName: string | symbol;
validators: ValidatorFn[];
}
/**
* Stores validation rules on the class prototype.
*/
function addValidationRule(target: Object, propertyName: string | symbol, validator: ValidatorFn) {
if (!Reflect.hasOwnMetadata(VALIDATION_METADATA, target)) {
Reflect.defineMetadata(VALIDATION_METADATA, [], target);
}
const validations = Reflect.getOwnMetadata(VALIDATION_METADATA, target) as FieldValidation[];
let fieldValidation = validations.find(fv => fv.propertyName === propertyName);
if (!fieldValidation) {
fieldValidation = { propertyName, validators: [] };
validations.push(fieldValidation);
}
fieldValidation.validators.push(validator);
}
/**
* Decorator factory for 'required' validation.
*/
export function Required() {
return <T, V>(
value: undefined,
context: ClassFieldDecoratorContext<T, V>
) => {
if (context.kind !== 'field') {
throw new Error(`@Required can only be applied to fields, not ${String(context.kind)}`);
}
context.addInitializer(function(this: T) {
addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
val === null || val === undefined || (typeof val === 'string' && val.trim() === '')
? `${String(context.name)} is required.`
: null
);
});
};
}
/**
* Decorator factory for 'minLength' validation.
*/
export function MinLength(length: number) {
return <T, V extends string>(
value: undefined,
context: ClassFieldDecoratorContext<T, V>
) => {
if (context.kind !== 'field') {
throw new Error(`@MinLength can only be applied to fields, not ${String(context.kind)}`);
}
context.addInitializer(function(this: T) {
addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
typeof val === 'string' && val.length < length
? `${String(context.name)} must be at least ${length} characters long.`
: null
);
});
};
}
/**
* Decorator factory for 'max' validation.
*/
export function Max(maxValue: number) {
return <T, V extends number>(
value: undefined,
context: ClassFieldDecoratorContext<T, V>
) => {
if (context.kind !== 'field') {
throw new Error(`@Max can only be applied to fields, not ${String(context.kind)}`);
}
context.addInitializer(function(this: T) {
addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
typeof val === 'number' && val > maxValue
? `${String(context.name)} must be at most ${maxValue}.`
: null
);
});
};
}
/**
* Class decorator to enable validation for a class.
* It adds a `validate()` method to the class prototype.
*/
export function Validatable() {
return <T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext<T>
) => {
if (context.kind !== 'class') {
throw new Error(`@Validatable can only be applied to classes, not ${String(context.kind)}`);
}
// Add a validate method to the class prototype
context.addInitializer(function(this: T) {
Object.defineProperty(this.prototype, 'validate', {
value: function(this: any): string[] {
const errors: string[] = [];
const validations = Reflect.getOwnMetadata(VALIDATION_METADATA, Object.getPrototypeOf(this)) as FieldValidation[] || [];
for (const fieldValidation of validations) {
const value = this[fieldValidation.propertyName];
for (const validator of fieldValidation.validators) {
const error = validator(value);
if (error) {
errors.push(error);
}
}
}
return errors;
},
writable: true,
configurable: true,
});
});
return target;
};
}
// Polyfill for Reflect.metadata if not available (e.g., in some environments or older TS versions)
// For native decorators, Reflect.metadata is not strictly required by the spec itself,
// but it's a common pattern for storing metadata.
// If you're using a bundler like Webpack/Rollup/ESBuild, ensure 'reflect-metadata' is imported once at the entry point.
// `import 'reflect-metadata';`
// For this example, we'll assume it's available or provide a minimal shim.
if (typeof Reflect === 'undefined' || !Reflect.hasOwnMetadata) {
console.warn("Reflect.metadata not found. Providing a minimal shim. For production, consider 'reflect-metadata' polyfill.");
const metadataMap = new WeakMap<object, Map<string | symbol, any>>();
(Reflect as any).defineMetadata = (key: string | symbol, value: any, target: object, propertyKey?: string | symbol) => {
let targetMetadata = metadataMap.get(target);
if (!targetMetadata) {
targetMetadata = new Map();
metadataMap.set(target, targetMetadata);
}
const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
targetMetadata.set(metadataKey, value);
};
(Reflect as any).getOwnMetadata = (key: string | symbol, target: object, propertyKey?: string | symbol) => {
const targetMetadata = metadataMap.get(target);
if (!targetMetadata) return undefined;
const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
return targetMetadata.get(metadataKey);
};
(Reflect as any).hasOwnMetadata = (key: string | symbol, target: object, propertyKey?: string | symbol) => {
const targetMetadata = metadataMap.get(target);
if (!targetMetadata) return false;
const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
return targetMetadata.has(metadataKey);
};
}
// --- Usage Example ---
@Validatable()
class Product {
@Required()
@MinLength(3)
name: string;
@Required()
@Max(9999)
price: number;
description?: string;
constructor(name: string, price: number, description?: string) {
this.name = name;
this.price = price;
this.description = description;
}
}
// Test cases
const product1 = new Product('Laptop', 1200);
const errors1 = (product1 as any).validate();
console.log('Product 1 errors:', errors1); // Expected: []
const product2 = new Product('', 50000);
const errors2 = (product2 as any).validate();
console.log('Product 2 errors:', errors2); // Expected: ["name is required.", "name must be at least 3 characters long.", "price must be at most 9999."]
const product3 = new Product('TV', 10000);
const errors3 = (product3 as any).validate();
console.log('Product 3 errors:', errors3); // Expected: ["price must be at most 9999."]
const product4 = new Product('A', 100);
const errors4 = (product4 as any).validate();
console.log('Product 4 errors:', errors4); // Expected: ["name must be at least 3 characters long."]
// Demonstrate that validate method is added to prototype
console.log('Product.prototype has validate method:', 'validate' in Product.prototype); // true
This validation system uses field decorators (@Required, @MinLength, @Max) to attach validation rules to properties. The @Validatable class decorator then injects a validate method onto the class prototype. This method iterates through the collected rules and executes them against the instance's properties. The addInitializer in field decorators ensures that validation rules are registered on the class prototype before any instances are created, making the validate method available immediately. Note the use of Reflect.defineMetadata and Reflect.getOwnMetadata for storing validation rules, which requires the reflect-metadata polyfill (or a shim as provided).
Architectural Comparison: Stage 3 Decorators vs. Legacy experimentalDecorators
| Feature | Stage 3 Decorators (TS 5.8+) | Legacy experimentalDecorators (TS < 5.8) |
|---|---|---|
| Specification Status | ECMAScript Stage 3 (near final) | Non-standard, experimental |
tsconfig.json | experimentalDecorators: false, emitDecoratorMetadata: false | experimentalDecorators: true, emitDecoratorMetadata: true (for DI) |
| Decorator Context | Rich ClassDecoratorContext, ClassMethodDecoratorContext, etc. | No explicit context object; arguments are target, key, descriptor |
addInitializer | Yes, allows running code after class definition or after instance construction | No direct equivalent; often required manual static property setup |
| Field Decorators | Receive undefined as value, return initializer function | Receive target, key; modify descriptor (if accessor) or target |
| Private Fields | Can decorate and interact with private fields via access | Cannot decorate private fields |
Reflect.metadata | Not inherently required by spec; used for custom metadata | Heavily relied upon for type reflection (e.g., for DI) |
| Runtime Behavior | Decorators run during class definition | Decorators run during class definition |
| Performance | Generally optimized, part of JS engine runtime | Can introduce overhead due to Reflect.metadata and polyfills |
| Maintainability | Standardized API, better long-term stability | API subject to change, less predictable |
Production Gotchas & Troubleshooting
-
experimentalDecoratorsStill Enabled:- Symptom: Decorators behave unexpectedly, or TypeScript complains about decorator syntax even with TS 5.8+.
- Cause: You likely still have
"experimentalDecorators": truein yourtsconfig.json. - Fix: Set
"experimentalDecorators": falseand"emitDecoratorMetadata": false. Ensure yourtargetisES2022or higher andmoduleResolutionisBundlerorNodeNext.
-
Reflect.metadataMissing:- Symptom: Runtime errors like
Reflect.defineMetadata is not a functionwhen using metadata patterns (like in the validation example). - Cause: The
reflect-metadatapolyfill is not imported or not available in your runtime environment. Native Stage 3 decorators do not inherently provideReflect.metadata. - Fix: Add
import 'reflect-metadata';at the very top of your application's entry point. Ensure thereflect-metadatapackage is installed (npm install reflect-metadata).
- Symptom: Runtime errors like
-
Type Information at Runtime:
- Symptom: In DI, you want to inject
ServiceAintoclass MyClass { @Inject() serviceA: ServiceA; }but the decorator cannot determineServiceA's type. - Cause: TypeScript types are erased at compile time.
emitDecoratorMetadata(which provides type info viaReflect.metadata) is for legacy decorators and is incompatible with Stage 3. - Fix: Explicitly pass the type to the decorator, e.g.,
@Inject(ServiceA). For constructor injection, you'd need to parse the constructor'sFunction.prototype.toString()or use a build-time step to extract types.
- Symptom: In DI, you want to inject
-
Decorator Order of Execution:
- Symptom: Unexpected behavior when multiple decorators are applied to the same target.
- Cause: Decorators are applied in a specific order:
- Field/Method/Accessor: From top to bottom, then evaluated from bottom to top.
- Class: Applied after all its members are decorated.
addInitializercallbacks run in the order they were added.
- Fix: Understand the execution order. If decorator A depends on decorator B's output, ensure B runs first. For
addInitializer, they run in the order they were registered.
-
thisContext inaddInitializer:- Symptom:
thisinsideaddInitializerrefers to the wrong object. - Cause: For
ClassDecoratorContext,thisrefers to the class constructor. ForClassFieldDecoratorContext,ClassMethodDecoratorContext, etc.,thisrefers to the instance being constructed. - Fix: Be mindful of the
thiscontext. For class-level operations (like adding a prototype method), useObject.getPrototypeOf(this)orthis.prototypeifthisis the constructor. For instance-level operations (like setting a field value),thisdirectly refers to the instance.
- Symptom:
Frequently Asked Questions
-
Can I use Stage 3 Decorators with React/Angular/Vue? Yes. Modern frameworks are adapting. Angular 17+ fully supports Stage 3 decorators. React and Vue don't inherently use decorators for their core components but can benefit from them in utility classes or services. Ensure your build tooling (Webpack, Vite, etc.) is configured to handle them (e.g., Babel with
@babel/plugin-proposal-decoratorsin2023-11mode). -
Are Stage 3 Decorators slower than regular functions? Decorators execute at class definition time, not at runtime for every instance creation (except for
addInitializercallbacks that run during instance construction). The overhead is minimal and typically negligible for most applications. The primary performance impact comes from what the decorators do (e.g., complex reflection or heavy computations). -
How do I debug decorators? Place
debugger;statements inside your decorator functions. Since they run during class definition, your debugger will pause when the class is being defined. ForaddInitializercallbacks, they will pause during instance creation. -
Can I decorate constructor parameters? The current Stage 3 Decorator proposal does not include parameter decorators. This was a feature of
experimentalDecoratorsbut was removed due to complexity and concerns about runtime type reflection. For DI with constructor parameters, you typically need to explicitly provide dependencies or use a build-time step to extract type information. -
What's the difference between
valueandcontextin decorator arguments?valueis the thing being decorated (e.g., the method function, the class constructor). For field decorators,valueisundefinedbecause fields don't have an initial "value" in the same way methods do.contextprovides metadata about the decorated element, such as itskind(class, method, field),name,staticstatus,privatestatus, and the crucialaddInitializerfunction.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TypeScript Alone Isn't Enough: End-to-End Type Safety with Zod in Next.js
TypeScript evaporates at runtime. Learn why static types fail at your API and Server Action boundaries, and how Zod delivers infallible schema validation and inferred typing.
Read more
React 19 Actions in Practice: useActionState, useOptimistic & Server Action Resiliency
Comprehensive guide covering react 19 actions in practice: useactionstate, useoptimistic & server action resiliency with production-grade architecture and code examples.
Read more
The Ultimate Guide to React Router in 2026
A comprehensive deep dive into React Router v6+ features, nested routing architectures, data loaders, and mastering modern state-driven navigation.
Read more