TypeScript Decorators: Adding Metadata and Enhancing Functionality

November 2, 2024 (2w ago)

TypeScript Decorators: Adding Metadata and Enhancing Functionality

Decorators in TypeScript provide a powerful way to add metadata, modify behavior, and enhance functionality in classes, properties, methods, and parameters. Decorators are widely used in frameworks like Angular to simplify configuration and keep code modular. In this guide, we’ll explore what decorators are, how to use them, and see practical examples that can help you build reusable, scalable applications in TypeScript.


What are TypeScript Decorators?

Decorators are special functions in TypeScript that allow you to add annotations or metadata to classes, methods, properties, and parameters. They’re similar to attributes in other languages and are evaluated at runtime. Decorators are currently an experimental feature in TypeScript, so they need to be enabled in the TypeScript configuration.

Enabling Decorators in TypeScript

To use decorators, you need to enable them in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

Types of Decorators in TypeScript

TypeScript supports four types of decorators:

  1. Class Decorators: Applied to classes to add metadata or modify the class behavior.
  2. Method Decorators: Applied to methods within classes, useful for logging, validation, or modifying method functionality.
  3. Property Decorators: Applied to class properties, typically used to add metadata.
  4. Parameter Decorators: Applied to parameters within methods, often used for dependency injection.

Class Decorators

Class decorators are functions that take a constructor as an argument and allow you to enhance or modify the class. They’re useful for logging, enforcing certain rules, or adding metadata.

Example: Adding Metadata with Class Decorators

function Entity(constructor: Function) {
  console.log(`Entity: ${constructor.name} has been created`);
}
 
@Entity
class User {
  constructor(public name: string) {}
}
 
const user = new User("Alice"); // Output: "Entity: User has been created"

In this example, Entity is a class decorator that logs a message when the class is created. The decorator adds behavior without modifying the class’s actual code.

Example: Extending Class Functionality with Decorators

function WithTimestamp<T extends { new(...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    timestamp = new Date();
  };
}
 
@WithTimestamp
class Order {
  constructor(public productId: number) {}
}
 
const order = new Order(101);
console.log(order.timestamp); // Outputs the current date and time

The WithTimestamp decorator adds a timestamp property to the Order class, extending its functionality in a reusable way.


Method Decorators

Method decorators are applied to class methods, allowing you to intercept method calls, log actions, or modify the return value. They’re ideal for adding cross-cutting concerns like logging, caching, or access control.

Example: Logging Method Calls

function Log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
 
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyName} with args: ${args.join(", ")}`);
    return method.apply(this, args);
  };
}
 
class Calculator {
  @Log
  add(a: number, b: number): number {
    return a + b;
  }
}
 
const calculator = new Calculator();
console.log(calculator.add(2, 3)); // Logs: "Calling add with args: 2, 3" and Output: 5

The Log decorator wraps the add method, logging its arguments before executing it. This helps track method calls without modifying the method itself.

Example: Validating Method Input

You can use decorators to add input validation to methods, ensuring that arguments meet certain conditions.

function Positive(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
 
  descriptor.value = function (...args: any[]) {
    if (args.some(arg => arg <= 0)) {
      throw new Error(`${propertyName} requires positive numbers`);
    }
    return method.apply(this, args);
  };
}
 
class MathOperations {
  @Positive
  multiply(a: number, b: number): number {
    return a * b;
  }
}
 
const math = new MathOperations();
console.log(math.multiply(5, 3)); // Output: 15
// console.log(math.multiply(-1, 3)); // Throws error: "multiply requires positive numbers"

The Positive decorator enforces that all arguments to multiply must be positive, throwing an error otherwise.


Property Decorators

Property decorators are applied to class properties and are often used to add metadata or track changes to properties.

Example: Adding Metadata to Properties

function ReadOnly(target: any, propertyName: string) {
  Object.defineProperty(target, propertyName, {
    writable: false
  });
}
 
class Book {
  @ReadOnly
  title = "TypeScript Essentials";
}
 
const book = new Book();
book.title = "New Title"; // Error: Cannot assign to read-only property 'title'

The ReadOnly decorator makes the title property immutable, enforcing that it cannot be modified after it’s set.

Example: Tracking Property Changes

function Track(target: any, propertyName: string) {
  let value = target[propertyName];
  
  Object.defineProperty(target, propertyName, {
    get: () => value,
    set: (newValue) => {
      console.log(`Setting ${propertyName} to ${newValue}`);
      value = newValue;
    },
  });
}
 
class Settings {
  @Track
  theme = "dark";
}
 
const settings = new Settings();
settings.theme = "light"; // Logs: "Setting theme to light"

In this example, Track logs every change to the theme property, providing a simple way to observe property modifications.


Parameter Decorators

Parameter decorators are applied to specific parameters in method definitions. They’re commonly used in frameworks for dependency injection, enabling more modular and testable code.

Example: Using Parameter Decorators for Metadata

function Required(target: any, propertyName: string, parameterIndex: number) {
  console.log(`Parameter at index ${parameterIndex} in ${propertyName} is required`);
}
 
class UserService {
  createUser(@Required name: string, @Required age: number) {
    console.log(`Creating user: ${name}, ${age}`);
  }
}
 
const service = new UserService();
service.createUser("Alice", 30); // Logs metadata for required parameters

The Required decorator adds metadata to specific parameters, which could later be used to enforce rules, validate input, or inject dependencies.


Practical Applications of Decorators

Decorators offer powerful ways to create clean and modular code, especially in larger applications. Here are some practical uses:

1. Logging and Debugging

By decorating methods with logging functions, you can track when and how often methods are called, simplifying debugging.

2. Input Validation

Method decorators are excellent for implementing validation rules on method inputs, ensuring that incorrect data doesn’t reach core logic.

3. Caching Results

You can use decorators to cache results of methods, especially for expensive or frequently called operations, improving performance.

4. Dependency Injection

Parameter decorators simplify dependency injection, providing a clean way to inject services or other dependencies into methods or constructors.

5. Access Control and Security

Decorators can enforce access control policies by restricting method calls to authorized users or roles, making it easier to secure your application.


Best Practices for Using Decorators

  1. Keep Decorators Modular: Make decorators reusable and avoid coupling them too tightly with specific methods or classes.
  2. Limit Side Effects: Avoid modifying the logic within methods directly; use decorators for logging, validation, or metadata instead.
  3. Test Decorators Independently: Test decorators separately from the methods they decorate to ensure they behave correctly.
  4. Document Decorator Usage: Clearly document any decorators used in your codebase, as they can change behavior in unexpected ways.
  5. Be Mindful of Performance: Some decorators, such as those for logging or caching, can impact performance, so use them judiciously.

Conclusion

TypeScript decorators are a powerful feature that can add functionality, enforce validation, and inject dependencies in a clean and modular way. By understanding the different types of decorators—class, method, property, and parameter decorators—you can create reusable, scalable, and maintainable code.

Whether you’re enhancing methods with logging, enforcing validation rules, or implementing caching, decorators provide a flexible approach to adding functionality in TypeScript applications. Start using decorators to simplify your code and enhance your TypeScript projects with clean, modular functionality.