JavaScript > TypeScript > Advanced TypeScript > Decorators

Class Decorator with Configuration

Demonstrates a TypeScript class decorator that enhances a class with configuration options. This decorator adds logging functionality based on provided configuration.

The Problem: Boilerplate Code and Configuration

Imagine needing to add similar logging or setup logic to multiple classes. Repeating the same code across your codebase leads to maintenance issues and potential inconsistencies. Decorators offer a clean, reusable solution.

Core Concepts: Decorators in TypeScript

Decorators are a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. They use the @expression form, where expression must evaluate to a function that will be called at runtime with information about the decorated declaration. In TypeScript, decorators are an experimental feature and require enabling the experimentalDecorators compiler option in your tsconfig.json file.

tsconfig.json Configuration

Before using decorators, ensure your tsconfig.json file has experimentalDecorators set to true and emitDecoratorMetadata set to true (if you plan on using reflection).

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./src",
    "paths": {
      "*": ["*"]
    },
    "lib": ["es2015", "es2017", "esnext.decorators"]
  },
  "include": ["src/**/*"]
}

The Code: Class Decorator with Configuration

This example defines a LogClass decorator factory. It takes a LoggerConfig object as an argument, allowing you to configure the logging behavior. The decorator enhances the decorated class by adding logging functionality to the constructor and providing a log method. The logLevel determines the severity of the log, and the prefix allows you to customize the log message's prefix. Explanation: * LoggerConfig: An interface defining the structure of the configuration object. It includes logLevel (specifying the logging level) and an optional prefix for the log messages. * LogClass(config: LoggerConfig): This is the decorator factory. It accepts a LoggerConfig and returns the actual decorator function. * return function<T extends { new (...args: any[]): {} }>(constructor: T): This is the decorator function itself. It takes the constructor of the class being decorated as an argument. * return class extends constructor: This extends the original class, adding the logging functionality. * The constructor logs a message when the class is instantiated. * The log method logs messages at the specified level using console.info, console.warn, or console.error. * @LogClass({ logLevel: 'warn', prefix: 'MyService' }): This applies the decorator to the MyService class with the provided configuration. The prefix will be MyService, and the logLevel will be warn. * When a MyService instance is created, the constructor will log a message. Calling service.doSomething() will then invoke the log method and log a warning message.

interface LoggerConfig {
  logLevel: 'info' | 'warn' | 'error';
  prefix?: string;
}

function LogClass(config: LoggerConfig) {
  return function<T extends { new (...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
      constructor(...args: any[]) {
        super(...args);
        const prefix = config.prefix || constructor.name;
        console.log(`[${prefix}] Class ${constructor.name} initialized.`);
      }

      log(message: string) {
        const prefix = config.prefix || constructor.name;
        switch (config.logLevel) {
          case 'info':
            console.info(`[${prefix}] INFO: ${message}`);
            break;
          case 'warn':
            console.warn(`[${prefix}] WARN: ${message}`);
            break;
          case 'error':
            console.error(`[${prefix}] ERROR: ${message}`);
            break;
        }
      }
    };
  };
}


@LogClass({ logLevel: 'warn', prefix: 'MyService' })
class MyService {
  constructor() {
    // Initialize service
  }

  doSomething() {
    this.log('Doing something important!');
  }
}

const service = new MyService();
service.doSomething();

Output

The output in the console will be: [MyService] Class MyService initialized. [MyService] WARN: Doing something important!

Real-Life Use Case

This pattern is commonly used for logging, performance monitoring, dependency injection, and feature toggles. You can use decorators to add cross-cutting concerns to your classes without modifying the core logic of those classes.

Best Practices

  • Keep decorators small and focused. Each decorator should address a single concern.
  • Use decorator factories to allow configuration of the decorator's behavior.
  • Document your decorators clearly, explaining their purpose and how to use them.

Interview Tip

Be prepared to explain the purpose of decorators, how they work, and give examples of common use cases. Understand the difference between decorator factories and simple decorators. Be prepared to discuss the experimentalDecorators compiler option.

When to Use Them

Use decorators when you need to add behavior to a class or its members in a reusable and declarative way. They are particularly useful for cross-cutting concerns that apply to multiple classes.

Memory footprint

Decorators, at runtime, add metadata and potentially additional methods or properties to the class. The memory footprint depends on the complexity of the decorator's logic and the amount of metadata it adds. Simple decorators generally have a minimal impact, while more complex decorators could add more overhead.

Alternatives

Alternatives to decorators include mixins, higher-order functions, and aspect-oriented programming (AOP) libraries. Mixins involve combining the properties and methods of multiple classes into a single class. Higher-order functions are functions that take other functions as arguments or return them. AOP libraries provide more comprehensive support for cross-cutting concerns but can be more complex to use.

Pros

  • Reusability: Decorators can be applied to multiple classes, reducing code duplication.
  • Declarative: Decorators provide a declarative way to add behavior to classes and their members.
  • Maintainability: Decorators can improve the maintainability of your code by separating concerns.

Cons

  • Complexity: Decorators can add complexity to your code if not used carefully.
  • Learning Curve: Understanding decorators requires a good understanding of TypeScript's type system and metaprogramming concepts.
  • Debugging: Debugging decorators can be challenging, especially when dealing with complex decorator logic.

FAQ

  • What is the difference between a decorator and a decorator factory?

    A decorator is a function that is directly applied to a declaration. A decorator factory is a function that returns a decorator. Decorator factories are used to configure the behavior of the decorator.
  • Do I always need to use 'emitDecoratorMetadata'?

    No. 'emitDecoratorMetadata' is only needed if you want to use reflection to inspect the types of the decorated elements at runtime. If you only need to modify the class itself, you can omit this option.