jsmanifest logojsmanifest

TypeScript Decorators in 2026: Practical Use Cases

TypeScript Decorators in 2026: Practical Use Cases

Most TypeScript decorators fail in production because teams copy patterns without understanding the 5.0+ execution model. Here's how to build decorators that solve real problems.

TypeScript Decorators in 2026: Practical Use Cases

Most TypeScript decorator implementations fail in production because teams copy legacy patterns from 2018 tutorials without understanding the fundamental execution model changes in TypeScript 5.0. The result is decorators that work in development but produce cryptic runtime errors when deployed, or worse, silently fail to execute in the order developers expect.

The TypeScript 5.0+ decorator syntax aligned with the Stage 3 ECMAScript proposal introduces breaking changes that make pre-5.0 decorator code incompatible. Teams migrating from experimentalDecorators discover their method decorators no longer receive the same arguments, class decorators can't modify constructors the same way, and the execution order reverses in specific scenarios. This matters because decorators that worked in TypeScript 4.9 will break silently in 5.0+ without compiler errors.

Understanding TypeScript 5.0+ Decorator Syntax

The modern decorator syntax operates on a completely different mental model than the experimental implementation. TypeScript 5.0 decorators receive a context object as their second argument instead of property descriptors, and they return replacement functions rather than mutating targets directly. The implication here is that decorators now compose predictably and integrate with JavaScript's native class feature proposal.

%% alt: TypeScript 5.0 decorator execution flow showing context object pattern
flowchart TD
    A[Decorator Applied] --> B{Decorator Type}
    B -->|Class| C[Receives: class, context]
    B -->|Method| D[Receives: method, context]
    B -->|Accessor| E[Receives: accessor, context]
    C --> F[Returns: New Class or void]
    D --> G[Returns: Replacement Method]
    E --> H[Returns: Replacement Accessor]
    F --> I[Context Object Contains:<br/>kind, name, metadata]
    G --> I
    H --> I
    I --> J[Metadata Survives<br/>Class Instantiation]

The context object contains critical information developers need for runtime introspection: kind identifies whether the decorator targets a class, method, field, or accessor; name provides the property key; and metadata offers a shared object for storing type information across decorators. This distinction is critical because the metadata object persists throughout the class lifecycle, enabling dependency injection and validation systems that legacy decorators couldn't support reliably.

TypeScript decorator syntax evolution

Practical Use Case 1: Performance Monitoring and Method Timing

Performance regression investigations consume engineering hours when teams lack granular timing data at the method level. A timing decorator captures execution duration without polluting business logic with performance measurement code, and it provides stack-aggregated metrics when methods call each other recursively.

function measurePerformance(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name);
 
  return function (this: any, ...args: any[]) {
    const start = performance.now();
    const result = target.apply(this, args);
 
    if (result instanceof Promise) {
      return result.finally(() => {
        const duration = performance.now() - start;
        console.log(`[${methodName}] async: ${duration.toFixed(2)}ms`);
      });
    }
 
    const duration = performance.now() - start;
    console.log(`[${methodName}] sync: ${duration.toFixed(2)}ms`);
    return result;
  };
}
 
class DataProcessor {
  @measurePerformance
  async fetchRecords(query: string): Promise<Record[]> {
    const response = await fetch(`/api/records?q=${query}`);
    return response.json();
  }
 
  @measurePerformance
  transformData(records: Record[]): TransformedData {
    return records.map(r => ({ id: r.id, value: r.value * 2 }));
  }
}

This implementation handles both synchronous and asynchronous methods correctly by detecting Promise return types and measuring completion time rather than invocation time. The failure mode here is subtle but expensive: decorators that don't check for Promise instances will report near-zero execution times for async operations, rendering the performance data meaningless.

Practical Use Case 2: Validation and Data Transformation

Runtime validation at API boundaries prevents invalid data from corrupting application state, but manual validation code scattered across controller methods creates maintenance debt. A validation decorator centralizes type checking and transformation logic, enforcing contracts without coupling business logic to validation libraries.

function validate(schema: ValidationSchema) {
  return function (
    target: Function,
    context: ClassMethodDecoratorContext
  ) {
    return function (this: any, ...args: any[]) {
      const validatedArgs = args.map((arg, index) => {
        const rule = schema.args[index];
        if (!rule) return arg;
 
        if (rule.type === 'number' && typeof arg !== 'number') {
          throw new TypeError(`Argument ${index} must be number`);
        }
 
        if (rule.required && (arg === null || arg === undefined)) {
          throw new TypeError(`Argument ${index} is required`);
        }
 
        return rule.transform ? rule.transform(arg) : arg;
      });
 
      return target.apply(this, validatedArgs);
    };
  };
}
 
type ValidationSchema = {
  args: Array<{
    type?: 'string' | 'number' | 'boolean';
    required?: boolean;
    transform?: (value: any) => any;
  }>;
};
 
class UserService {
  @validate({
    args: [
      { type: 'string', required: true },
      { 
        type: 'number', 
        required: true,
        transform: (age) => Math.max(0, Math.min(120, age))
      }
    ]
  })
  createUser(name: string, age: number): User {
    return { id: generateId(), name, age };
  }
}

The transform function in the validation schema enables data sanitization at method boundaries. In other words, the decorator enforces that age values fall within valid ranges before the business logic executes, preventing database constraint violations downstream.

Decorator validation pipeline

Practical Use Case 3: Dependency Injection and IoC Containers

Dependency injection containers eliminate constructor boilerplate and enable testability by replacing concrete dependencies with mocks. Modern decorators support metadata storage that survives class instantiation, making them ideal for marking injection points and storing type information that TypeScript's emit doesn't preserve at runtime.

const INJECT_METADATA = Symbol('inject:metadata');
 
function injectable() {
  return function (target: Function, context: ClassDecoratorContext) {
    context.metadata[INJECT_METADATA] = true;
  };
}
 
function inject(token: string) {
  return function (
    _target: undefined,
    context: ClassFieldDecoratorContext
  ) {
    context.metadata[context.name] = token;
  };
}
 
class Container {
  private services = new Map<string, any>();
 
  register(token: string, instance: any) {
    this.services.set(token, instance);
  }
 
  resolve<T>(target: new () => T): T {
    const metadata = (target as any)[Symbol.metadata];
    if (!metadata || !metadata[INJECT_METADATA]) {
      return new target();
    }
 
    const instance = Object.create(target.prototype);
    
    for (const [key, token] of Object.entries(metadata)) {
      if (key !== INJECT_METADATA && typeof token === 'string') {
        instance[key] = this.services.get(token);
      }
    }
 
    return instance;
  }
}
 
@injectable()
class PaymentService {
  @inject('logger') logger!: Logger;
  @inject('http') http!: HttpClient;
 
  async processPayment(amount: number): Promise<void> {
    this.logger.info(`Processing payment: ${amount}`);
    await this.http.post('/payments', { amount });
  }
}
 
const container = new Container();
container.register('logger', new ConsoleLogger());
container.register('http', new FetchHttpClient());
 
const paymentService = container.resolve(PaymentService);

The Symbol.metadata property provides a standardized location for storing decorator-generated metadata that survives minification and bundling. This approach scales to hundreds of services without performance degradation because metadata lookup occurs once during container resolution rather than on every method call.

Practical Use Case 4: Authorization Guards and Access Control

Authorization logic embedded in business methods creates security vulnerabilities when developers forget to add permission checks to new endpoints. A guard decorator enforces access control uniformly across controller methods, reducing the attack surface by centralizing security policy.

function requireRole(roles: string[]) {
  return function (
    target: Function,
    context: ClassMethodDecoratorContext
  ) {
    return function (this: any, ...args: any[]) {
      const user = this.getCurrentUser?.();
      
      if (!user) {
        throw new UnauthorizedError('Authentication required');
      }
 
      const hasRole = roles.some(role => user.roles.includes(role));
      if (!hasRole) {
        throw new ForbiddenError(
          `Requires one of: ${roles.join(', ')}`
        );
      }
 
      return target.apply(this, args);
    };
  };
}
 
class AdminController {
  private getCurrentUser(): User | null {
    return globalContext.user;
  }
 
  @requireRole(['admin', 'super_admin'])
  deleteUser(userId: string): void {
    database.users.delete(userId);
  }
 
  @requireRole(['admin', 'super_admin', 'moderator'])
  banUser(userId: string, reason: string): void {
    database.users.update(userId, { banned: true, banReason: reason });
  }
}

The guard decorator assumes the controller instance provides a getCurrentUser method, demonstrating how decorators compose with existing architectural patterns. Teams using Express middleware or Next.js API routes inject user context before controller instantiation, and the decorator consumes that context without coupling to specific frameworks.

Decorator Composition and Execution Order

Decorators execute bottom-to-top when stacked on the same target, opposite to the visual reading order. This execution model causes confusion when teams stack timing, validation, and authorization decorators, expecting them to run in declaration order.

The composition pattern that prevents ordering bugs applies decorators with explicit dependencies at the class level and independent decorators at the method level. In other words, authorization should decorate classes to ensure it runs first, while timing decorates methods to measure only business logic execution.

@requireAuthentication()
class SecureController {
  @measurePerformance
  @validate({ args: [{ type: 'string', required: true }] })
  processRequest(data: string): Result {
    return { processed: data.toUpperCase() };
  }
}

This stacking order ensures authentication runs first (class-level), then validation (outer method decorator), then timing (inner method decorator). The timing measurement excludes validation overhead, providing accurate business logic metrics.

Migration from Legacy Decorators and Best Practices

Teams migrating from experimentalDecorators encounter breaking changes in parameter order, return value handling, and metadata access. The TypeScript compiler won't catch these breaks because the decorator syntax remains valid—only the runtime behavior changes.

The migration strategy that minimizes risk converts decorators one subsystem at a time, starting with logging and timing decorators that have minimal side effects. Authorization and dependency injection decorators require more careful testing because they control application security and object graph construction. Teams running both decorator systems concurrently enable feature flags at the module level, allowing gradual rollout with instant rollback.

Production decorator implementations should validate context objects defensively, checking that context.kind matches expectations before accessing kind-specific properties. The failure mode for skipping validation manifests as runtime errors when decorators apply to unexpected targets, such as a method decorator applied to a class by mistake.

That covers the essential patterns for TypeScript 5.0+ decorators in production environments. Apply these in your codebase and the difference will be immediate: fewer security oversights, consistent performance visibility, and dependency injection that scales without framework lock-in.