TypeScript 5.8 Decorator Middleware: 6 Production Patterns from Metaprogramming to AOP Architecture
TypeScript Decorators Are Finally Production-Ready
Still using experimentalDecorators with emitDecoratorMetadata? Sweating every TypeScript upgrade fearing breakage? In 2026, the TC39 decorator proposal has reached Stage 3.7, and TypeScript 5.8 natively supports standard decorators — no more experimental flags, no more semantic ambiguity, decorator middleware is finally safe for production. From method interceptors to AOP architecture, from parameter validation to dependency injection, decorators are redefining TypeScript's metaprogramming paradigm.
This article starts from TC39 decorator core concepts and guides you through method interceptors & logging decorators → class transformers & metadata registration → parameter validation decorators → AOP middleware chains → dependency injection containers → decorator composition & pipeline patterns with 6 production patterns, from spec to implementation.
Key Takeaways
- The fundamental difference between TC39 standard decorators and Legacy decorators: replace/wrap instead of mutate
- 3-layer decorator middleware architecture: Definition (Decorator) → Registration (Metadata) → Execution (Middleware Chain)
- 6 production patterns covering logging, validation, AOP, DI, pipeline composition
- 5 common pitfalls and solutions, 10 common error troubleshooting steps
- Full comparison: TC39 Decorators vs Legacy Decorators vs Decorator Factories
Table of Contents
- TC39 Decorator Core Concepts
- Pattern 1: Method Interceptors & Logging Decorators
- Pattern 2: Class Transformers & Metadata Registration
- Pattern 3: Parameter Validation Decorators
- Pattern 4: AOP Middleware Chains
- Pattern 5: Dependency Injection Containers
- Pattern 6: Decorator Composition & Pipeline Patterns
- 5 Common Pitfalls & Solutions
- 10 Common Error Troubleshooting
- Advanced Optimization Tips
- Comparison: TC39 Decorators vs Legacy Decorators vs Decorator Factories
- Recommended Online Tools
- Summary
TC39 Decorator Core Concepts
| Concept | Description |
|---|---|
| TC39 Decorator | ECMAScript standard decorator proposal, Stage 3.7, natively supported in TypeScript 5.8 |
| Method Decorator | Intercepts method calls, can modify parameters/return values/exceptions |
| Class Decorator | Replaces or enhances class definitions, can inject metadata |
| Accessor Decorator | Intercepts getter/setter property access |
| Field Decorator | Triggered during initialization, can replace initial values |
| Decorator Context | Context object providing kind, name, access and other metadata |
| Add Initializer | Initialization hook that runs automatically when a class is instantiated |
| Metadata | Data attached to classes/methods via Symbols, used for DI, validation, etc. |
TC39 Decorator Execution Flow
Definition Phase:
@log Decorator factory invoked
@validate Returns decorator function
class UserService { Decorators execute bottom-to-top
@cache Method decorators execute first
async getUser(id: string) { Class decorator executes last
...
}
}
Runtime Phase:
const instance = new UserService() → Triggers addInitializer hooks
instance.getUser('123') → Triggers method decorator wrapper logic
Decorator Context (DecoratorContext):
{
kind: 'method' | 'class' | 'accessor' | 'field',
name: 'getUser' | 'UserService',
access: { get, set, has },
addInitializer(fn) → Register initialization hook,
static: boolean,
private: boolean
}
TC39 vs Legacy Decorator Core Differences
Legacy Decorators (experimentalDecorators):
@log Mutates original descriptor
class Svc { Order: top-to-bottom
descriptor.value = wrappedFn Directly mutates decorated object
}
TC39 Standard Decorators:
@log Returns replacement value
class Svc { Order: bottom-to-top
return { ...descriptor, Does NOT mutate original, returns new
method: wrappedFn } Functional, immutable
}
Pattern 1: Method Interceptors & Logging Decorators
Method interceptors are the most fundamental decorator middleware pattern — intercepting method calls without modifying the original code, adding cross-cutting concerns like logging, timing, and retries.
Basic Logging Decorator
type DecoratorTarget = {
[key: string]: (...args: any[]) => any;
};
function log(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacementMethod(this: DecoratorTarget, ...args: any[]) {
const startTime = performance.now();
const className = this.constructor.name;
console.log(`[${className}.${methodName}] Call started`, {
args: args.length > 0 ? args : undefined,
timestamp: new Date().toISOString(),
});
try {
const result = target.value.apply(this, args);
if (result instanceof Promise) {
return result.then(
(resolvedValue) => {
const duration = performance.now() - startTime;
console.log(`[${className}.${methodName}] Call succeeded`, {
duration: `${duration.toFixed(2)}ms`,
hasResult: resolvedValue !== undefined,
});
return resolvedValue;
},
(error) => {
const duration = performance.now() - startTime;
console.error(`[${className}.${methodName}] Async call failed`, {
duration: `${duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
);
}
const duration = performance.now() - startTime;
console.log(`[${className}.${methodName}] Call succeeded`, {
duration: `${duration.toFixed(2)}ms`,
});
return result;
} catch (error) {
const duration = performance.now() - startTime;
console.error(`[${className}.${methodName}] Sync call failed`, {
duration: `${duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
return replacementMethod;
}
Configurable Logging Decorator Factory
interface LogOptions {
level?: 'debug' | 'info' | 'warn' | 'error';
includeArgs?: boolean;
includeResult?: boolean;
maxArgLength?: number;
slowThreshold?: number;
}
function createLogDecorator(options: LogOptions = {}) {
const {
level = 'info',
includeArgs = true,
includeResult = false,
maxArgLength = 200,
slowThreshold = 1000,
} = options;
return function logDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacementMethod(this: DecoratorTarget, ...args: any[]) {
const startTime = performance.now();
const className = this.constructor.name;
const logData: Record<string, unknown> = {
method: `${className}.${methodName}`,
timestamp: new Date().toISOString(),
};
if (includeArgs && args.length > 0) {
const serializedArgs = JSON.stringify(args);
logData.args = serializedArgs.length > maxArgLength
? serializedArgs.slice(0, maxArgLength) + '...'
: args;
}
const logFn = console[level] || console.info;
logFn(`[LOG] ${logData.method} called`, logData);
try {
const result = target.value.apply(this, args);
if (result instanceof Promise) {
return result.then(
(resolvedValue) => {
const duration = performance.now() - startTime;
const resultLogData: Record<string, unknown> = {
method: `${className}.${methodName}`,
duration: `${duration.toFixed(2)}ms`,
};
if (includeResult) {
resultLogData.result = resolvedValue;
}
if (duration > slowThreshold) {
console.warn(
`[SLOW] ${className}.${methodName} took ${duration.toFixed(2)}ms`,
resultLogData
);
} else {
logFn(`[LOG] ${className}.${methodName} completed`, resultLogData);
}
return resolvedValue;
},
(error) => {
const duration = performance.now() - startTime;
console.error(`[ERROR] ${className}.${methodName} async failed`, {
duration: `${duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
);
}
const duration = performance.now() - startTime;
const resultLogData: Record<string, unknown> = {
method: `${className}.${methodName}`,
duration: `${duration.toFixed(2)}ms`,
};
if (includeResult) {
resultLogData.result = result;
}
logFn(`[LOG] ${className}.${methodName} completed`, resultLogData);
return result;
} catch (error) {
const duration = performance.now() - startTime;
console.error(`[ERROR] ${className}.${methodName} sync failed`, {
duration: `${duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
return replacementMethod;
};
}
const logVerbose = createLogDecorator({
level: 'debug',
includeArgs: true,
includeResult: true,
slowThreshold: 500,
});
const logMinimal = createLogDecorator({
level: 'info',
includeArgs: false,
includeResult: false,
slowThreshold: 2000,
});
Usage Example
class PaymentService {
@logVerbose
async processPayment(orderId: string, amount: number): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 100));
if (amount <= 0) {
throw new Error('Amount must be greater than 0');
}
return `PAY-${orderId}-${Date.now()}`;
}
@logMinimal
getPaymentStatus(paymentId: string): string {
return 'completed';
}
}
const paymentService = new PaymentService();
await paymentService.processPayment('ORD-001', 99.99);
Pattern 2: Class Transformers & Metadata Registration
Class decorators can replace entire class definitions or inject metadata via addInitializer during instantiation. This is the foundation for dependency injection, route registration, and ORM mapping.
Class Decorator Basics: Route Metadata Registration
const ROUTE_METADATA_KEY = Symbol('route-metadata');
const CONTROLLER_METADATA_KEY = Symbol('controller-metadata');
interface RouteMetadata {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
handlerName: string;
}
interface ControllerMetadata {
basePath: string;
routes: RouteMetadata[];
}
function Controller(basePath: string) {
return function <T extends { new (...args: any[]): any }>(
target: T,
context: ClassDecoratorContext
) {
const routes: RouteMetadata[] = Reflect.getMetadata(
ROUTE_METADATA_KEY,
target.prototype
) || [];
const metadata: ControllerMetadata = {
basePath,
routes,
};
Reflect.defineMetadata(
CONTROLLER_METADATA_KEY,
metadata,
target
);
return class extends target {
constructor(...args: any[]) {
super(...args);
console.log(`[Controller] ${basePath} registered with ${routes.length} routes`);
}
};
};
}
function Route(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const handlerName = String(context.name);
const existingRoutes: RouteMetadata[] = Reflect.getMetadata(
ROUTE_METADATA_KEY,
context.static ? target : target
) || [];
existingRoutes.push({ method, path, handlerName });
Reflect.defineMetadata(
ROUTE_METADATA_KEY,
existingRoutes,
context.static ? target : target
);
return target.value;
};
}
Metadata Registration & Query
const VALIDATION_METADATA_KEY = Symbol('validation-metadata');
const FIELD_METADATA_KEY = Symbol('field-metadata');
interface FieldMetadata {
fieldName: string;
type: string;
required: boolean;
validator?: (value: any) => boolean;
errorMessage?: string;
}
interface ValidationMetadata {
fields: FieldMetadata[];
schemaName: string;
}
function Field(options: Omit<FieldMetadata, 'fieldName'> = {}) {
return function (
target: ClassAccessorDecoratorTarget<any, any>,
context: ClassAccessorDecoratorContext
) {
const fieldName = String(context.name);
const fieldMeta: FieldMetadata = {
fieldName,
type: options.type || 'string',
required: options.required ?? true,
validator: options.validator,
errorMessage: options.errorMessage,
};
context.addInitializer(function (this: any) {
const existingFields: FieldMetadata[] = Reflect.getMetadata(
FIELD_METADATA_KEY,
this.constructor
) || [];
existingFields.push(fieldMeta);
Reflect.defineMetadata(FIELD_METADATA_KEY, existingFields, this.constructor);
});
return {
get(this: any) {
return target.get.call(this);
},
set(this: any, value: any) {
if (fieldMeta.required && (value === null || value === undefined)) {
throw new Error(`${fieldName} is required`);
}
if (fieldMeta.validator && !fieldMeta.validator(value)) {
throw new Error(fieldMeta.errorMessage || `${fieldName} validation failed`);
}
target.set.call(this, value);
},
};
};
}
function Validatable(schemaName: string) {
return function <T extends { new (...args: any[]): any }>(
target: T,
context: ClassDecoratorContext
) {
return class extends target {
constructor(...args: any[]) {
super(...args);
const fields: FieldMetadata[] = Reflect.getMetadata(
FIELD_METADATA_KEY,
this.constructor
) || [];
const metadata: ValidationMetadata = { fields, schemaName };
Reflect.defineMetadata(VALIDATION_METADATA_KEY, metadata, this.constructor);
}
static getValidationSchema(): ValidationMetadata {
const fields: FieldMetadata[] = Reflect.getMetadata(
FIELD_METADATA_KEY,
this
) || [];
return { fields, schemaName };
}
validate(): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const fields: FieldMetadata[] = Reflect.getMetadata(
FIELD_METADATA_KEY,
this.constructor
) || [];
for (const field of fields) {
const value = (this as any)[field.fieldName];
if (field.required && (value === null || value === undefined)) {
errors.push(`${field.fieldName} is required`);
}
if (field.validator && !field.validator(value)) {
errors.push(field.errorMessage || `${field.fieldName} validation failed`);
}
}
return { valid: errors.length === 0, errors };
}
};
};
}
Usage Example
@Validatable('User')
class User {
@Field({ type: 'string', required: true })
accessor name: string = '';
@Field({
type: 'string',
required: true,
validator: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
errorMessage: 'Invalid email format',
})
accessor email: string = '';
@Field({ type: 'number', required: false })
accessor age: number = 0;
}
const user = new User();
user.name = 'John';
user.email = 'john@example.com';
user.age = 28;
const result = user.validate();
console.log(result);
const schema = User.getValidationSchema();
console.log(schema);
Pattern 3: Parameter Validation Decorators
Parameter validation is one of the most practical decorator middleware scenarios — automatically validating parameter types and constraints before method execution, preventing runtime type errors.
Type Validation Decorator
interface ValidationRule {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: RegExp;
custom?: (value: any) => boolean;
message?: string;
}
function validateParams(...rules: ValidationRule[]) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacementMethod(this: DecoratorTarget, ...args: any[]) {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
const value = args[i];
const paramName = `param${i + 1}`;
if (rule.required && (value === null || value === undefined)) {
throw new TypeError(
`[${this.constructor.name}.${methodName}] ${paramName} is required`
);
}
if (value !== null && value !== undefined) {
const actualType = Array.isArray(value) ? 'array' : typeof value;
if (actualType !== rule.type) {
throw new TypeError(
`[${this.constructor.name}.${methodName}] ${paramName} type error: expected ${rule.type}, got ${actualType}`
);
}
if (rule.type === 'string') {
if (rule.minLength && value.length < rule.minLength) {
throw new RangeError(
`[${this.constructor.name}.${methodName}] ${paramName} min length ${rule.minLength}`
);
}
if (rule.maxLength && value.length > rule.maxLength) {
throw new RangeError(
`[${this.constructor.name}.${methodName}] ${paramName} max length ${rule.maxLength}`
);
}
if (rule.pattern && !rule.pattern.test(value)) {
throw new Error(
rule.message || `[${this.constructor.name}.${methodName}] ${paramName} invalid format`
);
}
}
if (rule.type === 'number') {
if (rule.min !== undefined && value < rule.min) {
throw new RangeError(
`[${this.constructor.name}.${methodName}] ${paramName} min value ${rule.min}`
);
}
if (rule.max !== undefined && value > rule.max) {
throw new RangeError(
`[${this.constructor.name}.${methodName}] ${paramName} max value ${rule.max}`
);
}
}
if (rule.custom && !rule.custom(value)) {
throw new Error(
rule.message || `[${this.constructor.name}.${methodName}] ${paramName} validation failed`
);
}
}
}
return target.value.apply(this, args);
}
return replacementMethod;
};
}
Schema Validation Decorator (Zod Integration)
import { z, ZodSchema } from 'zod';
function validateWithSchema(schemas: ZodSchema[]) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
function replacementMethod(this: DecoratorTarget, ...args: any[]) {
for (let i = 0; i < schemas.length; i++) {
if (i >= args.length) continue;
const result = schemas[i].safeParse(args[i]);
if (!result.success) {
const errors = result.error.issues.map(
(issue) => `${issue.path.join('.')}: ${issue.message}`
);
throw new Error(
`[${this.constructor.name}.${methodName}] param${i + 1} validation failed: ${errors.join('; ')}`
);
}
args[i] = result.data;
}
return target.value.apply(this, args);
}
return replacementMethod;
};
}
Usage Example
const OrderIdSchema = z.string().uuid();
const CreateOrderSchema = z.object({
productId: z.string().min(1),
quantity: z.number().int().min(1).max(999),
price: z.number().positive(),
address: z.object({
city: z.string().min(1),
street: z.string().min(1),
zipCode: z.string().regex(/^\d{6}$/),
}),
});
class OrderService {
@validateParams(
{ type: 'string', required: true, pattern: /^[A-Z]{2}-\d{4}$/, message: 'Order ID format: XX-0000' },
{ type: 'number', required: true, min: 0, max: 1000000 }
)
createOrder(orderId: string, amount: number): string {
return `Order ${orderId} created, amount ${amount}`;
}
@validateWithSchema([OrderIdSchema, CreateOrderSchema])
async processOrder(orderId: string, orderData: z.infer<typeof CreateOrderSchema>) {
return { orderId, status: 'processed', ...orderData };
}
}
const orderService = new OrderService();
orderService.createOrder('CN-1234', 99.99);
try {
orderService.createOrder('invalid', -1);
} catch (error) {
console.error(error);
}
Pattern 4: AOP Middleware Chains
AOP (Aspect-Oriented Programming) is the most powerful application of decorator middleware — decoupling cross-cutting concerns like logging, caching, authorization, and retries from business logic through composable middleware chains.
Middleware Core Architecture
Request Flow
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Auth │───▶│ Cache │───▶│ Retry │───▶│ Log │
│Middleware│ │Middleware│ │Middleware│ │Middleware│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐
│ Business │
│ getUser() │
└──────────┘
│
▼
Response
Middleware Interface Definition
interface MiddlewareContext {
className: string;
methodName: string;
args: any[];
result?: any;
error?: Error;
metadata: Map<string, any>;
startTime: number;
}
type NextFunction = () => Promise<any>;
interface Middleware {
name: string;
execute(context: MiddlewareContext, next: NextFunction): Promise<any>;
}
class MiddlewareChain {
private middlewares: Middleware[] = [];
use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}
async execute(
className: string,
methodName: string,
args: any[],
target: (...args: any[]) => any,
thisArg: any
): Promise<any> {
const context: MiddlewareContext = {
className,
methodName,
args,
metadata: new Map(),
startTime: performance.now(),
};
let index = 0;
const dispatch = async (): Promise<any> => {
if (index >= this.middlewares.length) {
context.result = await target.apply(thisArg, args);
return context.result;
}
const middleware = this.middlewares[index++];
return middleware.execute(context, dispatch);
};
return dispatch();
}
}
Built-in Middleware Implementations
class AuthMiddleware implements Middleware {
name = 'auth';
constructor(private permissionChecker: (method: string) => boolean) {}
async execute(context: MiddlewareContext, next: NextFunction): Promise<any> {
const hasPermission = this.permissionChecker(context.methodName);
if (!hasPermission) {
throw new Error(`[${context.className}.${context.methodName}] Permission denied`);
}
context.metadata.set('auth.checked', true);
return next();
}
}
class CacheMiddleware implements Middleware {
name = 'cache';
private cache = new Map<string, { value: any; expireAt: number }>();
constructor(private ttl: number = 60000) {}
async execute(context: MiddlewareContext, next: NextFunction): Promise<any> {
const cacheKey = `${context.className}:${context.methodName}:${JSON.stringify(context.args)}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expireAt > Date.now()) {
context.metadata.set('cache.hit', true);
return cached.value;
}
context.metadata.set('cache.hit', false);
const result = await next();
this.cache.set(cacheKey, {
value: result,
expireAt: Date.now() + this.ttl,
});
return result;
}
invalidate(pattern?: string): void {
if (!pattern) {
this.cache.clear();
return;
}
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key);
}
}
}
}
class RetryMiddleware implements Middleware {
name = 'retry';
constructor(
private maxRetries: number = 3,
private delayMs: number = 1000,
private retryOn: (error: Error) => boolean = () => true
) {}
async execute(context: MiddlewareContext, next: NextFunction): Promise<any> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const result = await next();
if (attempt > 1) {
context.metadata.set('retry.attempts', attempt);
}
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < this.maxRetries && this.retryOn(lastError)) {
const delay = this.delayMs * Math.pow(2, attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw lastError;
}
}
throw lastError!;
}
}
class LoggingMiddleware implements Middleware {
name = 'logging';
async execute(context: MiddlewareContext, next: NextFunction): Promise<any> {
const startTime = performance.now();
const logPrefix = `[${context.className}.${context.methodName}]`;
console.log(`${logPrefix} call started`, {
args: context.args,
timestamp: new Date().toISOString(),
});
try {
const result = await next();
const duration = performance.now() - startTime;
console.log(`${logPrefix} call succeeded`, {
duration: `${duration.toFixed(2)}ms`,
cacheHit: context.metadata.get('cache.hit'),
});
return result;
} catch (error) {
const duration = performance.now() - startTime;
console.error(`${logPrefix} call failed`, {
duration: `${duration.toFixed(2)}ms`,
error: error instanceof Error ? error.message : String(error),
retryAttempts: context.metadata.get('retry.attempts'),
});
throw error;
}
}
}
AOP Decorator
const globalMiddlewareChain = new MiddlewareChain();
globalMiddlewareChain
.use(new AuthMiddleware((method) => true))
.use(new CacheMiddleware(30000))
.use(new RetryMiddleware(3, 1000))
.use(new LoggingMiddleware());
function UseMiddleware(chain?: MiddlewareChain) {
const middlewareChain = chain || globalMiddlewareChain;
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const originalMethod = target.value;
const methodName = String(context.name);
async function replacementMethod(this: any, ...args: any[]) {
return middlewareChain.execute(
this.constructor.name,
methodName,
args,
originalMethod,
this
);
}
return replacementMethod;
};
}
class UserService {
@UseMiddleware()
async getUser(id: string): Promise<{ id: string; name: string }> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Failed to fetch user: ${response.status}`);
return response.json();
}
@UseMiddleware()
async createUser(data: { name: string; email: string }): Promise<string> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to create user: ${response.status}`);
return response.json();
}
}
Pattern 5: Dependency Injection Containers
Dependency Injection (DI) is the most critical decorator middleware pattern in enterprise applications — declaring dependencies through decorators while the container automatically resolves and injects instances.
DI Container Implementation
const INJECT_METADATA_KEY = Symbol('inject');
const INJECTABLE_METADATA_KEY = Symbol('injectable');
const SCOPE_METADATA_KEY = Symbol('scope');
enum InjectionScope {
Singleton = 'singleton',
Transient = 'transient',
Request = 'request',
}
interface InjectionToken<T = any> {
name: string;
__type?: T;
}
interface Provider<T = any> {
token: InjectionToken<T>;
useClass?: new (...args: any[]) => T;
useFactory?: (...args: any[]) => T | Promise<T>;
useValue?: T;
scope?: InjectionScope;
}
class DIContainer {
private providers = new Map<InjectionToken, Provider>();
private instances = new Map<InjectionToken, any>();
private factories = new Map<InjectionToken, (...args: any[]) => any>();
register<T>(provider: Provider<T>): this {
this.providers.set(provider.token, provider);
if (provider.useValue !== undefined) {
this.instances.set(provider.token, provider.useValue);
}
if (provider.useFactory) {
this.factories.set(provider.token, provider.useFactory);
}
return this;
}
async resolve<T>(token: InjectionToken<T>): Promise<T> {
const existingInstance = this.instances.get(token);
if (existingInstance !== undefined) {
return existingInstance;
}
const provider = this.providers.get(token);
if (!provider) {
throw new Error(`Unregistered dependency: ${token.name}`);
}
let instance: T;
if (provider.useClass) {
instance = await this.createInstance(provider.useClass);
} else if (provider.useFactory) {
const deps = await this.resolveFactoryDeps(provider.useFactory);
instance = await provider.useFactory(...deps);
} else {
throw new Error(`Provider ${token.name} missing implementation`);
}
if (provider.scope !== InjectionScope.Transient) {
this.instances.set(token, instance);
}
return instance;
}
private async createInstance<T>(TargetClass: new (...args: any[]) => T): Promise<T> {
const injections: { index: number; token: InjectionToken }[] =
Reflect.getMetadata(INJECT_METADATA_KEY, TargetClass) || [];
const args: any[] = [];
for (const injection of injections) {
args[injection.index] = await this.resolve(injection.token);
}
return new TargetClass(...args);
}
private async resolveFactoryDeps(
factory: (...args: any[]) => any
): Promise<any[]> {
return [];
}
createScope(): DIContainer {
const scoped = new DIContainer();
for (const [token, provider] of this.providers) {
scoped.providers.set(token, provider);
}
for (const [token, instance] of this.instances) {
scoped.instances.set(token, instance);
}
return scoped;
}
}
const globalContainer = new DIContainer();
DI Decorators
function Injectable(scope: InjectionScope = InjectionScope.Singleton) {
return function <T extends { new (...args: any[]): any }>(
target: T,
context: ClassDecoratorContext
) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
Reflect.defineMetadata(SCOPE_METADATA_KEY, scope, target);
const token: InjectionToken = { name: target.name };
globalContainer.register({
token,
useClass: target,
scope,
});
return target;
};
}
function Inject(token: InjectionToken) {
return function (
target: any,
context: ClassMethodDecoratorContext | ClassFieldDecoratorContext
) {
if (context.kind === 'method') {
const existingInjections: { index: number; token: InjectionToken }[] =
Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];
existingInjections.push({ index: 0, token });
Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target);
}
};
}
function AutoInject(token?: InjectionToken) {
return function (
target: any,
context: ClassFieldDecoratorContext
) {
const resolvedToken = token || { name: String(context.name) };
return {
get(this: any) {
if (!this[`__injected_${String(context.name)}`]) {
this[`__injected_${String(context.name)}`] = globalContainer.resolve(resolvedToken);
}
return this[`__injected_${String(context.name)}`];
},
};
};
}
Usage Example
const TOKENS = {
Logger: { name: 'Logger' } as InjectionToken<LoggerService>,
Database: { name: 'Database' } as InjectionToken<DatabaseService>,
UserRepository: { name: 'UserRepository' } as InjectionToken<UserRepository>,
UserService: { name: 'UserService' } as InjectionToken<UserService>,
};
@Injectable(InjectionScope.Singleton)
class LoggerService {
log(message: string, ...args: any[]) {
console.log(`[Logger] ${message}`, ...args);
}
error(message: string, ...args: any[]) {
console.error(`[Logger] ${message}`, ...args);
}
}
@Injectable(InjectionScope.Singleton)
class DatabaseService {
private connected = false;
async connect(): Promise<void> {
this.connected = true;
console.log('[DB] Database connected');
}
async query<T>(sql: string, params?: any[]): Promise<T[]> {
if (!this.connected) throw new Error('Database not connected');
console.log(`[DB] Execute query: ${sql}`);
return [] as T[];
}
}
@Injectable(InjectionScope.Transient)
class UserRepository {
constructor(
private db: DatabaseService,
private logger: LoggerService
) {}
async findById(id: string) {
this.logger.log(`Find user: ${id}`);
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
@Injectable(InjectionScope.Singleton)
class UserService {
@AutoInject(TOKENS.UserRepository)
accessor userRepo!: UserRepository;
@AutoInject(TOKENS.Logger)
accessor logger!: LoggerService;
async getUser(id: string) {
this.logger.log(`Get user: ${id}`);
return this.userRepo.findById(id);
}
}
globalContainer.register({ token: TOKENS.Logger, useClass: LoggerService });
globalContainer.register({ token: TOKENS.Database, useClass: DatabaseService });
globalContainer.register({ token: TOKENS.UserRepository, useClass: UserRepository });
globalContainer.register({ token: TOKENS.UserService, useClass: UserService });
const userService = await globalContainer.resolve(TOKENS.UserService);
await userService.getUser('user-001');
Pattern 6: Decorator Composition & Pipeline Patterns
The most elegant use of decorators is composition — multiple decorators execute in pipeline order, each focusing on a single aspect, achieving complex functionality through combination.
Pipeline-style Decorator Composition
interface PipeContext<TInput, TOutput> {
input: TInput;
output?: TOutput;
metadata: Map<string, any>;
skip?: boolean;
error?: Error;
}
type PipeHandler<TInput, TOutput> = (
context: PipeContext<TInput, TOutput>,
next: () => Promise<PipeContext<TInput, TOutput>>
) => Promise<PipeContext<TInput, TOutput>>;
class Pipeline<TInput, TOutput> {
private handlers: PipeHandler<TInput, TOutput>[] = [];
pipe(handler: PipeHandler<TInput, TOutput>): this {
this.handlers.push(handler);
return this;
}
async execute(input: TInput): Promise<TOutput> {
let context: PipeContext<TInput, TOutput> = {
input,
metadata: new Map(),
};
let index = 0;
const dispatch = async (): Promise<PipeContext<TInput, TOutput>> => {
if (index >= this.handlers.length || context.skip) {
return context;
}
const handler = this.handlers[index++];
return handler(context, dispatch);
};
context = await dispatch();
if (context.error) {
throw context.error;
}
return context.output as TOutput;
}
}
Decorator Pipeline Composition
function Timeout(ms: number) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const originalMethod = target.value;
async function replacementMethod(this: any, ...args: any[]) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ms);
try {
const result = await Promise.race([
originalMethod.apply(this, args),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Method timeout: ${ms}ms`)), ms)
),
]);
clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
return replacementMethod;
};
}
function Debounce(ms: number) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const originalMethod = target.value;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
function replacementMethod(this: any, ...args: any[]) {
if (timeoutId) {
clearTimeout(timeoutId);
}
return new Promise((resolve) => {
timeoutId = setTimeout(() => {
resolve(originalMethod.apply(this, args));
}, ms);
});
}
return replacementMethod;
};
}
function Throttle(ms: number) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const originalMethod = target.value;
let lastCallTime = 0;
function replacementMethod(this: any, ...args: any[]) {
const now = Date.now();
if (now - lastCallTime < ms) {
return undefined;
}
lastCallTime = now;
return originalMethod.apply(this, args);
}
return replacementMethod;
};
}
function Memoize(keyResolver?: (...args: any[]) => string) {
return function (
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const originalMethod = target.value;
const cache = new Map<string, { value: any; expireAt: number }>();
const defaultTtl = 5 * 60 * 1000;
async function replacementMethod(this: any, ...args: any[]) {
const key = keyResolver ? keyResolver(...args) : JSON.stringify(args);
const cached = cache.get(key);
if (cached && cached.expireAt > Date.now()) {
return cached.value;
}
const result = await originalMethod.apply(this, args);
cache.set(key, { value: result, expireAt: Date.now() + defaultTtl });
return result;
}
return replacementMethod;
};
}
Composition Usage Example
class SearchService {
@logMinimal
@Timeout(5000)
@Memoize((query: string) => `search:${query}`)
async search(query: string): Promise<string[]> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();
}
@Debounce(300)
@logMinimal
async autoComplete(prefix: string): Promise<string[]> {
const response = await fetch(`/api/suggest?q=${encodeURIComponent(prefix)}`);
return response.json();
}
@Throttle(1000)
@logMinimal
trackSearch(query: string): void {
console.log(`Search tracked: ${query}`);
}
}
Decorator Execution Order
Method call → @logMinimal → @Timeout → @Memoize → Original method
│
Method return ← @logMinimal ← @Timeout ← @Memoize ← Original result
Note: TC39 decorator wrapping order is bottom-to-top (closest to method wraps first)
But runtime invocation is outside-in (outermost decorator intercepts first)
5 Common Pitfalls & Solutions
Pitfall 1: Lost this Context in Decorators
// Wrong
function badDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
return function (...args: any[]) {
// this context may be lost!
return target.value(...args);
};
}
// Correct
function goodDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
return target.value.apply(this, args);
};
}
Pitfall 2: Forgetting to Return Promise in Async Decorators
// Wrong
function badAsyncDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
const result = target.value.apply(this, args);
// Forgot to handle Promise, errors won't be caught
console.log('Done');
return result;
};
}
// Correct
function goodAsyncDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
return async function (this: any, ...args: any[]) {
try {
const result = await target.value.apply(this, args);
console.log('Done');
return result;
} catch (error) {
console.error('Failed', error);
throw error;
}
};
}
Pitfall 3: Wrong Decorator Order Causing Logic Issues
// Wrong order: cache before validation, may cache invalid data
class BadService {
@Cache()
@Validate()
async getData(id: string) { /* ... */ }
}
// Correct order: validation before cache
class GoodService {
@Validate()
@Cache()
async getData(id: string) { /* ... */ }
}
// Mnemonic: Validate → Cache → Log → Business
Pitfall 4: Accessing Instance Properties in addInitializer
// Wrong: addInitializer runs before the constructor
function badInitDecorator(
target: any,
context: ClassFieldDecoratorContext
) {
context.addInitializer(function (this: any) {
// Instance properties may not be initialized yet!
console.log(this.name);
});
}
// Correct: Only register hooks in addInitializer, don't access instance properties
function goodInitDecorator(
target: any,
context: ClassFieldDecoratorContext
) {
context.addInitializer(function (this: any) {
// Only register metadata, don't access instance properties
const existingInits: string[] = Reflect.getMetadata(
Symbol('inits'),
this.constructor
) || [];
existingInits.push(String(context.name));
Reflect.defineMetadata(Symbol('inits'), existingInits, this.constructor);
});
}
Pitfall 5: Mixing Legacy and TC39 Decorators
// tsconfig.json cannot enable both modes simultaneously
// Wrong config:
{
"compilerOptions": {
"experimentalDecorators": true, // Legacy mode
"decorators": { "version": "2022-03" } // TC39 mode
// These are mutually exclusive! Pick one
}
}
// Correct config (TC39 standard decorators):
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"experimentalDecorators": false
// TypeScript 5.8+ uses TC39 standard decorators by default
}
}
// Migration strategy: migrate file by file, don't mix
// 1. New files use TC39 decorators
// 2. Old files keep Legacy, enforce via eslint rules
// 3. After migration complete, disable experimentalDecorators
10 Common Error Troubleshooting
| Error Message | Cause | Solution |
|---|---|---|
Decorators are not enabled |
Decorators not enabled in tsconfig | Set "experimentalDecorators": true (Legacy) or upgrade to TS 5.8+ |
Invalid decorator context kind |
Decorator type doesn't match target | Check context.kind, ensure method decorator is used on methods |
Cannot read property 'value' of undefined |
Legacy decorator syntax used with TC39 | TC39 decorator first param is { value, ... } object |
this is undefined in decorator |
Arrow function causing lost this |
Use function keyword for replacementMethod |
Decorators must return void or replace value |
Decorator returned invalid value | Method decorator returns function, class decorator returns class |
Metadata reflection not available |
reflect-metadata not installed | npm install reflect-metadata and import at entry point |
Decorator applied to constructor parameter |
TC39 doesn't support parameter decorators | Use method decorator + runtime validation instead |
Circular dependency detected |
DI container circular dependency | Use LazyInject or refactor dependency relationships |
Property 'addInitializer' does not exist |
TypeScript version too old | Upgrade to TypeScript 5.0+ |
Experimental feature warning |
Using Legacy decorators | Migrate to TC39 standard decorators |
Advanced Optimization Tips
1. Decorator Performance: Lazy Initialization
function LazyInit(factory: () => any) {
return function (
target: any,
context: ClassFieldDecoratorContext
) {
return {
get(this: any) {
const key = `__lazy_${String(context.name)}`;
if (!this[key]) {
this[key] = factory.call(this);
}
return this[key];
},
};
};
}
class ExpensiveService {
@LazyInit(() => new HeavyComputation())
accessor engine: any;
}
2. Conditional Decorators
function Conditional(
condition: () => boolean,
decorator: Function
) {
return function (...args: any[]) {
if (condition()) {
return (decorator as any)(...args);
}
return undefined;
};
}
const isProduction = () => process.env.NODE_ENV === 'production';
class ApiService {
@Conditional(isProduction, logMinimal)
async fetchData(url: string) {
return fetch(url).then((r) => r.json());
}
}
3. Decorator Metadata Serialization
const SERIALIZABLE_KEY = Symbol('serializable');
interface SerializableField {
name: string;
type: string;
serializer?: (value: any) => any;
deserializer?: (value: any) => any;
}
function Serializable(options?: Partial<SerializableField>) {
return function (
target: any,
context: ClassFieldDecoratorContext
) {
context.addInitializer(function (this: any) {
const fields: SerializableField[] = Reflect.getMetadata(
SERIALIZABLE_KEY,
this.constructor
) || [];
fields.push({
name: String(context.name),
type: options?.type || 'string',
serializer: options?.serializer,
deserializer: options?.deserializer,
});
Reflect.defineMetadata(SERIALIZABLE_KEY, fields, this.constructor);
});
};
}
function toJSON(instance: any): Record<string, any> {
const fields: SerializableField[] = Reflect.getMetadata(
SERIALIZABLE_KEY,
instance.constructor
) || [];
const result: Record<string, any> = {};
for (const field of fields) {
const value = instance[field.name];
result[field.name] = field.serializer ? field.serializer(value) : value;
}
return result;
}
4. Decorator Debugging Tool
function DebugDecorator(
target: DecoratorTarget,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
console.group(`🔍 ${this.constructor.name}.${methodName}`);
console.log('Arguments:', args);
console.trace('Call stack');
try {
const result = target.value.apply(this, args);
if (result instanceof Promise) {
return result.then(
(value) => {
console.log('Async result:', value);
console.groupEnd();
return value;
},
(error) => {
console.error('Async error:', error);
console.groupEnd();
throw error;
}
);
}
console.log('Sync result:', result);
console.groupEnd();
return result;
} catch (error) {
console.error('Sync error:', error);
console.groupEnd();
throw error;
}
};
}
Comparison: TC39 Decorators vs Legacy Decorators vs Decorator Factories
| Feature | TC39 Standard Decorators | Legacy Decorators | Decorator Factories |
|---|---|---|---|
| Spec Status | Stage 3.7, approaching Stage 4 | Deprecated, backward-compat only | Factory pattern based on TC39 |
| TypeScript Support | 5.0+ native | Requires experimentalDecorators |
Based on TC39 |
| Execution Order | Bottom-to-top (closest wraps first) | Top-to-bottom | Same as TC39 |
| Mutation Style | Returns new value, doesn't mutate original | Directly mutates descriptor | Same as TC39 |
| Parameter Decorators | Not supported | Supported | Not supported |
| Metadata Support | Manual or reflect-metadata | Requires emitDecoratorMetadata |
Manual |
| this Binding | Safe (via replacement function) | Needs attention | Safe |
| addInitializer | Supported (instantiation hook) | Not supported | Supported |
| Immutability | High (functional replacement) | Low (mutable modification) | High |
| Ecosystem Compat | Major frameworks migrating | Current mainstream | Recommended |
| Future Direction | Standard specification | Gradual deprecation | Best practice |
Migration Checklist
Legacy → TC39 Migration Steps:
□ 1. Upgrade TypeScript to 5.0+
□ 2. Disable experimentalDecorators
□ 3. Change decorator params from (target, key, descriptor) to (target, context)
□ 4. Change descriptor.value to target.value
□ 5. Change descriptor.get/set to target.get/set
□ 6. Use context.addInitializer instead of constructor injection
□ 7. Remove parameter decorators, use method decorators + runtime validation
□ 8. Update reflect-metadata usage
□ 9. Run full test suite
□ 10. Migrate incrementally, don't change everything at once
Recommended Online Tools
- JSON Formatter — Format decorator metadata JSON output
- Code Formatter — Format TypeScript decorator code
- Regex Cheatsheet — Common regex patterns used in validation decorators
Related Reading
- TypeScript Runtime Type Checking: 6 Production Patterns from Zod to TypeBox — Runtime type validation paired with decorator validation
- TypeScript Zod Validation: 5 Enterprise Patterns for Type Safety — Zod Schema integration with decorator validation
- TypeScript Effect System: Elegant Side Effect Handling with Effect-TS — Effect-TS dependency injection vs decorator DI comparison
External References
- TC39 Decorators Proposal — ECMAScript decorator standard proposal
- TypeScript 5.0 Release Notes - Decorators — TypeScript official decorator documentation
Summary
TypeScript 5.8 decorator middleware has moved from experimental to production-ready in 2026. TC39 standard decorators' immutability, functional replacement, and addInitializer hooks make decorator middleware a powerful tool for implementing AOP architecture, dependency injection, and parameter validation.
Key Takeaways:
- Method Interceptors: Logging, timing, retries as cross-cutting concerns without invading business code
- Class Transformers: Metadata registration, route mapping, ORM mapping via addInitializer injection
- Parameter Validation: Type checking, Schema validation, intercepting invalid inputs before method execution
- AOP Middleware Chains: Auth→Cache→Retry→Log pipeline execution, decoupling cross-cutting concerns
- Dependency Injection: Decorator-declared dependencies with automatic container resolution, supporting Singleton/Transient/Request scopes
- Decorator Composition: Pipeline pattern combining multiple decorators, each focused on a single aspect
Recommendation: New projects should use TC39 standard decorators directly; existing projects should create a migration plan for gradual transition. The core value of decorator middleware is — let business code focus on business, and let decorators handle cross-cutting concerns.
Try these browser-local tools — no sign-up required →