Decorator Pattern in TypeScript: Method Decorators

Method decorators are functions that modify or replace class methods at definition time. Unlike class decorators that target the constructor or property decorators that work with fields, method...

Key Insights

  • Method decorators intercept function calls by wrapping the original method, giving you a powerful hook to add cross-cutting concerns like logging, caching, and authorization without polluting business logic.
  • The PropertyDescriptor.value property holds the original method reference—replacing it with a wrapper function is the core technique for all method decorators.
  • Decorator execution order matters: decorators apply bottom-up but execute top-down, which affects how you compose authorization, validation, and logging layers.

Introduction to Method Decorators

Method decorators are functions that modify or replace class methods at definition time. Unlike class decorators that target the constructor or property decorators that work with fields, method decorators specifically intercept function behavior. This makes them ideal for cross-cutting concerns—functionality that spans multiple methods but doesn’t belong in the methods themselves.

The practical applications are everywhere: logging method calls for debugging, caching expensive computations, enforcing authorization rules, measuring performance, retrying failed operations, and validating inputs. Instead of scattering this logic throughout your codebase, decorators let you declare intent with a simple @DecoratorName annotation.

Here’s the basic syntax:

function MyDecorator(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor | void {
  // Modify or replace the method
  return descriptor;
}

class Example {
  @MyDecorator
  someMethod() {
    // Original implementation
  }
}

The decorator runs once when the class is defined, not when the method is called. Your decorator receives metadata about the method and can modify its behavior before any instance exists.

Understanding the Decorator Signature

Every method decorator receives three parameters that give you complete control over the decorated method:

target: The prototype of the class (for instance methods) or the constructor function itself (for static methods). This lets you access other properties and methods on the class.

propertyKey: The name of the method as a string. Useful for logging, cache key generation, or dynamic behavior based on method names.

descriptor: A PropertyDescriptor object containing the method’s configuration. This is where the magic happens.

The PropertyDescriptor has several properties, but value is the critical one—it holds the actual function reference. By replacing descriptor.value with a new function that calls the original, you create a wrapper.

function InspectDecorator(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): void {
  console.log('Target:', target.constructor.name);
  console.log('Property Key:', propertyKey);
  console.log('Descriptor:', {
    value: typeof descriptor.value,
    writable: descriptor.writable,
    enumerable: descriptor.enumerable,
    configurable: descriptor.configurable
  });
}

class UserService {
  @InspectDecorator
  findById(id: string): string {
    return `User ${id}`;
  }
}

// Output when class is defined:
// Target: UserService
// Property Key: findById
// Descriptor: { value: 'function', writable: true, enumerable: false, configurable: true }

Understanding these parameters is essential. The target gives you class context, propertyKey identifies the method, and descriptor.value is the function you’ll wrap.

Building Your First Method Decorator

Let’s build a practical logging decorator that tracks method entry, exit, and execution time. The key technique is replacing descriptor.value with a wrapper function that calls the original.

function Log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const className = target.constructor.name;
    const timestamp = new Date().toISOString();
    
    console.log(`[${timestamp}] ${className}.${propertyKey} called with:`, args);
    
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const duration = performance.now() - start;
    
    console.log(`[${timestamp}] ${className}.${propertyKey} returned:`, result);
    console.log(`[${timestamp}] Execution time: ${duration.toFixed(2)}ms`);
    
    return result;
  };

  return descriptor;
}

class OrderService {
  private orders: Map<string, { total: number }> = new Map();

  @Log
  calculateTotal(orderId: string, taxRate: number): number {
    const order = this.orders.get(orderId);
    if (!order) throw new Error('Order not found');
    return order.total * (1 + taxRate);
  }
}

Notice the use of originalMethod.apply(this, args). This preserves the correct this context—critical when the decorated method accesses instance properties. Using an arrow function for the wrapper would break this binding, so always use a regular function expression.

For async methods, the pattern requires awaiting the result:

function LogAsync(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const originalMethod = descriptor.value;

  descriptor.value = async function (...args: any[]) {
    console.log(`Calling ${propertyKey}`);
    const result = await originalMethod.apply(this, args);
    console.log(`${propertyKey} completed`);
    return result;
  };

  return descriptor;
}

Decorator Factories: Adding Configuration

Plain decorators can’t accept parameters. To configure behavior, you need a decorator factory—a function that returns a decorator. This uses closures to capture configuration values.

function Throttle(delayMs: number) {
  const lastCall = new Map<string, number>();

  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const now = Date.now();
      const key = `${target.constructor.name}.${propertyKey}`;
      const last = lastCall.get(key) || 0;

      if (now - last < delayMs) {
        console.log(`Throttled: ${key} (wait ${delayMs - (now - last)}ms)`);
        return undefined;
      }

      lastCall.set(key, now);
      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

class NotificationService {
  @Throttle(5000) // Only allow one call per 5 seconds
  sendAlert(message: string): void {
    console.log(`ALERT: ${message}`);
    // Send to external service
  }
}

const service = new NotificationService();
service.sendAlert('First'); // Sends
service.sendAlert('Second'); // Throttled

The outer function captures delayMs, and the inner function is the actual decorator. This pattern enables highly configurable decorators for retry logic, rate limiting, timeout handling, and more.

Practical Patterns: Caching and Memoization

Memoization caches method results based on arguments. Here’s a production-ready implementation with TTL (time-to-live) support:

interface CacheEntry<T> {
  value: T;
  expiry: number;
}

function Memoize(ttlMs: number = 60000) {
  const cache = new Map<string, CacheEntry<any>>();

  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const cacheKey = `${propertyKey}:${JSON.stringify(args)}`;
      const now = Date.now();
      const cached = cache.get(cacheKey);

      if (cached && cached.expiry > now) {
        console.log(`Cache hit: ${cacheKey}`);
        return cached.value;
      }

      console.log(`Cache miss: ${cacheKey}`);
      const result = originalMethod.apply(this, args);
      
      cache.set(cacheKey, {
        value: result,
        expiry: now + ttlMs
      });

      return result;
    };

    return descriptor;
  };
}

class PricingService {
  @Memoize(30000) // Cache for 30 seconds
  calculateDiscount(customerId: string, orderTotal: number): number {
    // Expensive calculation or external API call
    console.log('Computing discount...');
    return orderTotal * 0.1;
  }
}

For async methods, ensure you cache the resolved value, not the promise (unless you specifically want promise caching to deduplicate concurrent requests).

Composing Multiple Decorators

Real applications stack multiple decorators. Understanding execution order prevents subtle bugs.

Decorators apply bottom-up (closest to the method first) but execute top-down (outermost wrapper runs first). Think of it like wrapping a gift—the innermost layer goes on first, but you unwrap from the outside.

function Auth(role: string) {
  return function (target: any, key: string, desc: PropertyDescriptor) {
    const original = desc.value;
    desc.value = function (...args: any[]) {
      console.log(`[Auth] Checking role: ${role}`);
      // In reality, check user context
      const userRole = 'admin'; // Simulated
      if (userRole !== role && role !== 'any') {
        throw new Error('Unauthorized');
      }
      return original.apply(this, args);
    };
    return desc;
  };
}

function Validate(schema: (args: any[]) => boolean) {
  return function (target: any, key: string, desc: PropertyDescriptor) {
    const original = desc.value;
    desc.value = function (...args: any[]) {
      console.log('[Validate] Checking arguments');
      if (!schema(args)) {
        throw new Error('Validation failed');
      }
      return original.apply(this, args);
    };
    return desc;
  };
}

function LogExecution(target: any, key: string, desc: PropertyDescriptor) {
  const original = desc.value;
  desc.value = function (...args: any[]) {
    console.log(`[Log] Entering ${key}`);
    const result = original.apply(this, args);
    console.log(`[Log] Exiting ${key}`);
    return result;
  };
  return desc;
}

class PaymentService {
  @LogExecution
  @Auth('admin')
  @Validate((args) => args[0] > 0)
  processRefund(amount: number): string {
    return `Refunded $${amount}`;
  }
}

const payments = new PaymentService();
payments.processRefund(100);
// Output:
// [Log] Entering processRefund
// [Auth] Checking role: admin
// [Validate] Checking arguments
// [Log] Exiting processRefund

The execution flows: Log → Auth → Validate → Original Method → Validate returns → Auth returns → Log returns. Place authorization outermost to fail fast, validation next, and logging at the edges to capture everything.

Testing and Debugging Considerations

Testing decorated methods requires testing both the decorator logic and the integration with real methods.

// decorators/log.decorator.ts
export function Log(logger: { log: (msg: string) => void }) {
  return function (target: any, key: string, desc: PropertyDescriptor) {
    const original = desc.value;
    desc.value = function (...args: any[]) {
      logger.log(`Calling ${key}`);
      return original.apply(this, args);
    };
    return desc;
  };
}

// __tests__/log.decorator.test.ts
describe('Log Decorator', () => {
  it('should log method calls', () => {
    const mockLogger = { log: jest.fn() };

    class TestService {
      @Log(mockLogger)
      doWork(value: number): number {
        return value * 2;
      }
    }

    const service = new TestService();
    const result = service.doWork(5);

    expect(result).toBe(10);
    expect(mockLogger.log).toHaveBeenCalledWith('Calling doWork');
  });

  it('should preserve this context', () => {
    const mockLogger = { log: jest.fn() };

    class TestService {
      private multiplier = 3;

      @Log(mockLogger)
      calculate(value: number): number {
        return value * this.multiplier;
      }
    }

    const service = new TestService();
    expect(service.calculate(4)).toBe(12);
  });
});

Common pitfalls to avoid:

  1. Forgetting apply(this, args): Breaks instance method access to this
  2. Not handling async: Wrap with async/await for promise-returning methods
  3. Mutating shared state: Decorator factories with shared caches can leak between instances if not scoped properly
  4. Ignoring return values: Always return the original method’s result

Method decorators are a powerful tool for clean, maintainable TypeScript. Start with simple logging, then graduate to caching and authorization. The pattern scales from small utilities to full-featured frameworks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.