A Practical Guide to TypeScript Decorators

TypeScript Decorators - A Practical Guide

As TypeScript continues to evolve, decorators remain one of its most powerful yet underutilized features. While they’ve been experimental for years, the upcoming ECMAScript decorator proposal and TypeScript’s robust implementation make them invaluable for enterprise applications. If you’ve been working with TypeScript extensively, you’ve likely encountered decorators in frameworks like Angular or NestJS, but let’s dive deep into creating our own and understanding their full potential.

What Are Decorators Really?

Decorators are essentially functions that modify classes, methods, properties, or parameters at design time. Let’s think of them as higher-order functions for your class declarations. They provide a clean way to add metadata, modify behavior, or implement cross-cutting concerns without cluttering your core business logic.

// The @ symbol is syntactic sugar for this:
const DecoratedClass = MyDecorator(class OriginalClass {
  // class body
});

Understanding Class and Method Decorators

Class Decorators

Class decorators receive the constructor function and can return a new constructor or modify the existing one. They’re perfect for implementing patterns like singleton, adding metadata, or extending functionality.

function Singleton<T extends new (...args: any[]) => any>(constructor: T) {
  let instance: T | null = null;
  
  return class extends constructor {
    constructor(...args: any[]) {
      if (instance) {
        return instance;
      }
      super(...args);
      instance = this as any;
      return this;
    }
  };
}

@Singleton
class DatabaseConnection {
  constructor(private connectionString: string) {}
  
  connect() {
    console.log(`Connecting to ${this.connectionString}`);
  }
}

// Both instances will be the same object
const db1 = new DatabaseConnection("mongodb://localhost");
const db2 = new DatabaseConnection("postgresql://localhost");
console.log(db1 === db2); // true

Method Decorators

Method decorators receive three parameters: the target object, property name, and property descriptor. They’re excellent for implementing logging, caching, validation, or performance monitoring.

function Benchmark(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
  
  descriptor.value = function (...args: any[]) {
    const start = performance.now();
    const result = method.apply(this, args);
    const end = performance.now();
    
    console.log(`${propertyName} executed in ${end - start}ms`);
    return result;
  };
}

function Memoize(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
  const cache = new Map();
  
  descriptor.value = function (...args: any[]) {
    const key = JSON.stringify(args);
    
    if (cache.has(key)) {
      console.log(`Cache hit for ${propertyName}`);
      return cache.get(key);
    }
    
    const result = method.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

class MathService {
  @Benchmark
  @Memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

Creating Custom Decorators

Decorator Factories

For more flexibility, you can use decorator factories that return decorators. This allows you to pass configuration options.

function Retry(maxAttempts: number = 3, delay: number = 1000) {
  return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const method = descriptor.value;
    
    descriptor.value = async function (...args: any[]) {
      let lastError: Error;
      
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await method.apply(this, args);
        } catch (error) {
          lastError = error as Error;
          
          if (attempt === maxAttempts) {
            throw new Error(`Method ${propertyName} failed after ${maxAttempts} attempts: ${lastError.message}`);
          }
          
          console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    };
  };
}

function RateLimit(requestsPerMinute: number) {
  const requestTimes: number[] = [];
  
  return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const method = descriptor.value;
    
    descriptor.value = function (...args: any[]) {
      const now = Date.now();
      const oneMinuteAgo = now - 60000;
      
      // Remove old requests
      while (requestTimes.length > 0 && requestTimes[0] < oneMinuteAgo) {
        requestTimes.shift();
      }
      
      if (requestTimes.length >= requestsPerMinute) {
        throw new Error(`Rate limit exceeded: ${requestsPerMinute} requests per minute`);
      }
      
      requestTimes.push(now);
      return method.apply(this, args);
    };
  };
}

class ApiService {
  @Retry(3, 500)
  @RateLimit(10)
  async fetchUserData(userId: string) {
    // Simulated API call that might fail
    if (Math.random() < 0.5) {
      throw new Error('Network error');
    }
    return { id: userId, name: 'John Doe' };
  }
}

Output examples:

  1. Random number less than 0.5 (eg: 0.34), causing retries:
Attempt 1 failed, retrying in 500ms...
Attempt 2 failed, retrying in 500ms...
User Data: { id: '12345', name: 'John Doe' }
  1. Random number greater than 0.5 (eg: 0.75), no retries:
User Data: { id: '12345', name: 'John Doe' }

Property and Parameter Decorators

Property Decorators

Property decorators are called when a property is declared. They’re useful for metadata collection, validation setup, or property transformation.

function MinLength(length: number) {
  return function (target: any, propertyName: string) {
    // Store metadata about validation rules
    Reflect.defineMetadata('minLength', length, target, propertyName);
  };
}

function Email(target: any, propertyName: string) {
  Reflect.defineMetadata('isEmail', true, target, propertyName);
}

function Validate(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
  
  descriptor.value = function (...args: any[]) {
    // Validate all properties with metadata
    for (const prop in this) {
      const minLength = Reflect.getMetadata('minLength', this, prop);
      const isEmail = Reflect.getMetadata('isEmail', this, prop);
      
      if (minLength && this[prop].length < minLength) {
        throw new Error(`${prop} must be at least ${minLength} characters`);
      }
      
      if (isEmail && !this[prop].includes('@')) {
        throw new Error(`${prop} must be a valid email`);
      }
    }
    
    return method.apply(this, args);
  };
}

class User {
  @MinLength(3)
  username: string;
  
  @Email
  email: string;
  
  constructor(username: string, email: string) {
    this.username = username;
    this.email = email;
  }
  
  @Validate
  save() {
    console.log('User saved successfully');
  }
}

Parameter Decorators

Parameter decorators are called for each parameter in a method. They’re often used for dependency injection or parameter validation.

const injectionTokens = new Map();

function Inject(token: string) {
  return function (target: any, propertyName: string | undefined, parameterIndex: number) {
    const existingTokens = injectionTokens.get(target) || [];
    existingTokens[parameterIndex] = token;
    injectionTokens.set(target, existingTokens);
  };
}

function Injectable(target: any) {
  const originalConstructor = target;
  
  return class extends originalConstructor {
    constructor(...args: any[]) {
      const tokens = injectionTokens.get(originalConstructor.prototype) || [];
      const dependencies = tokens.map((token: string) => {
        // Simple service locator pattern
        return ServiceLocator.get(token);
      });
      
      super(...dependencies);
    }
  };
}

// Simple service locator
class ServiceLocator {
  private static services = new Map();
  
  static register(token: string, service: any) {
    this.services.set(token, service);
  }
  
  static get(token: string) {
    return this.services.get(token);
  }
}

@Injectable
class OrderService {
  constructor(
    @Inject('UserRepository') private userRepo: any,
    @Inject('EmailService') private emailService: any
  ) {}
  
  processOrder(userId: string) {
    const user = this.userRepo.findById(userId);
    this.emailService.sendConfirmation(user.email);
  }
}