Mastering TypeScript Decorators: Advanced Patterns and Use Cases

Explore advanced TypeScript decorator patterns and practical use cases to enhance your coding skills with this beginner-friendly tutorial.

TypeScript decorators provide a powerful way to add metadata and modify classes, methods, properties, and parameters at design time. Initially popular in frameworks like Angular, decorators enable clean, reusable code patterns. This tutorial will help you master advanced decorator techniques and practical use cases even if you are new to decorators.

Before diving into advanced patterns, let's quickly recap what decorators are. A decorator is a special kind of declaration attached to a class or its members. It allows you to intercept and modify behavior by wrapping or annotating the original element.

TypeScript supports four decorator types: class decorators, method decorators, property decorators, and parameter decorators. Each receives specific arguments that let you hook into the class's structure.

Let's start by creating an advanced class decorator that adds a timestamp to class instances.

typescript
function Timestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    createdAt = new Date();
  };
}

@Timestamp
class User {
  constructor(public name: string) {}
}

const user = new User('John');
console.log(user.createdAt); // Prints creation timestamp

The Timestamp decorator replaces the original class with a subclass that adds a createdAt property. This is a simple pattern to augment class instances without modifying the original class code.

Next, let's explore method decorators that can help with logging method calls and their arguments.

typescript
function LogMethod(
  target: Object,
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${String(propertyKey)} with`, args);
    const result = originalMethod.apply(this, args);
    console.log(`Result of ${String(propertyKey)}`, result);
    return result;
  };
  return descriptor;
}

class Calculator {
  @LogMethod
  add(a: number, b: number) {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(5, 3);

This method decorator wraps the original method to log input arguments and result, helping with debugging and tracing function calls.

Another useful pattern is using property decorators to enforce validation rules. Check out this example that restricts a property to positive numbers only.

typescript
function Positive(target: any, propertyKey: string) {
  let value = target[propertyKey];

  const getter = () => value;
  const setter = (newVal: number) => {
    if (newVal <= 0) {
      throw new Error(`${propertyKey} must be positive.`);
    }
    value = newVal;
  };

  Object.defineProperty(target, propertyKey, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true,
  });
}

class BankAccount {
  @Positive
  balance: number = 0;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }
}

const account = new BankAccount(100);
account.balance = 50; // works
// account.balance = -10; // Throws error: balance must be positive.

Here, the Positive decorator overrides the property’s getter and setter, adding a check to ensure the balance can never be set to zero or negative.

Finally, parameter decorators allow you to access metadata about parameters but can be combined with libraries like Reflect Metadata for advanced use. Here's a simple example to log parameter positions.

typescript
function LogParameter(target: Object, propertyKey: string | symbol, parameterIndex: number) {
  const existingLoggedParams: number[] = Reflect.getOwnMetadata('log_params', target, propertyKey) || [];
  existingLoggedParams.push(parameterIndex);
  Reflect.defineMetadata('log_params', existingLoggedParams, target, propertyKey);
}

class Person {
  greet(@LogParameter message: string) {
    console.log(message);
  }
}

// Note: To fully utilize parameter decorators, use `reflect-metadata` package and enable emitDecoratorMetadata.

In summary, TypeScript decorators can be used in many advanced ways, including augmenting classes, wrapping methods for cross-cutting concerns like logging, enforcing validation on properties, and managing metadata on parameters. These powerful patterns help write cleaner, more maintainable code.

To further explore decorators, ensure you have the "experimentalDecorators" and "emitDecoratorMetadata" options enabled in your tsconfig.json:

typescript
{
  "compilerOptions": {
    "target": "ES6",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Happy decorating! With practice, you can leverage decorators to write expressive, efficient TypeScript code.