TypeScript Introduction to Decorators

Decorators in TypeScript are a powerful feature that allows you to modify or extend the behavior of classes, methods, properties, and parameters at design time. If you have used frameworks like Angular or NestJS, you have already seen them in action (e.g., @Component or @Get). They are a form of meta-programming, which essentially means writing code that manages or modifies other code. By using decorators, you can add "cross-cutting concerns" like logging, validation, or dependency injection without cluttering your core logic.

Developer Tip: Think of decorators as "wrappers." They allow you to wrap a piece of code with additional functionality, similar to the Higher-Order Component (HOC) pattern in React or Middleware in Express.

 

What are Decorators?

A decorator is a special declaration that can be attached to a class, method, accessor, property, or parameter. Technically, a decorator is just a function that is called with specific arguments depending on where it is placed. You invoke them using the @expression syntax, where expression must evaluate to a function.

Decorators allow you to "annotate" your code. For instance, you can tell a framework that a specific class is a "Controller" or that a specific property should be hidden when the object is converted to JSON.

Watch Out: Decorators are still a stage 3 proposal in JavaScript. TypeScript’s current implementation is "experimental," meaning the syntax could change in future versions of the language. However, they are stable enough that major frameworks rely on them heavily.

 

Enabling Decorators in TypeScript

Because decorators are not yet a standard part of ECMAScript, TypeScript requires you to explicitly opt-in to use them. You do this by modifying your tsconfig.json file.

Example: Enabling Decorators

{
  "compilerOptions": {
    "target": "ES6",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • experimentalDecorators: This enables the decorator syntax.
  • emitDecoratorMetadata: This is often required by dependency injection libraries (like Inversify or NestJS) to store type information about the decorated code.

 

Types of Decorators

There are five main types of decorators in TypeScript, each receiving a different set of arguments:

Class Decorators
Applied to a class constructor. Use these to freeze a class, add new properties to the prototype, or replace the class entirely.

Method Decorators
Applied to methods. Great for logging execution time, checking permissions, or catching errors globally.

Accessor Decorators
Applied to getters and setters. Similar to method decorators, but used specifically for property access logic.

Property Decorators
Applied to class properties. These are often used to record metadata about the property (e.g., for database mapping or validation).

Parameter Decorators
Applied to method parameters. Usually used in conjunction with class or method decorators to mark specific arguments for special handling.

 

Class Decorators

A class decorator is called when the class is defined, not when it is instantiated. It receives the constructor of the class as its only argument.

Example: Class Decorator

function Sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
  console.log(`Class ${constructor.name} has been sealed.`);
}

@Sealed
class UserReport {
  constructor(public title: string) {}
}

// Any attempt to extend or modify the prototype of UserReport will now fail at runtime.
  • In this example, the Sealed decorator prevents the class and its prototype from being modified at runtime. This is a common pattern for security or preventing accidental overrides in large codebases.
Common Mistake: Beginners often expect class decorators to run every time an instance is created (using new). In reality, the decorator runs only once when the script is loaded and the class is defined.

 

Method Decorators

A method decorator is incredibly useful for utility logic. It receives three parameters: the prototype of the class, the name of the method, and a PropertyDescriptor.

Example: Method Decorator (Performance Tracker)

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

  descriptor.value = function(...args: any[]) {
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} took ${(end - start).toFixed(2)}ms`);
    return result;
  };
}

class DataProcessor {
  @LogExecutionTime
  processHeavyData() {
    // Simulate a heavy task
    for(let i = 0; i < 1000000; i++) {} 
  }
}

const processor = new DataProcessor();
processor.processHeavyData(); // Output: processHeavyData took 1.45ms
  • The PropertyDescriptor allows us to intercept the original function, run our logic (timing), and then execute the original logic.
Best Practice: Always use .apply(this, args) when overriding a method in a decorator to ensure the this context of the class instance is preserved.

 

Property Decorators

Property decorators are slightly different. They don't have access to the PropertyDescriptor and only receive the target object and the property name. They are mostly used to watch for property changes or to attach metadata.

Example: Property Decorator

function MaxValue(max: number) {
  return function(target: any, propertyKey: string) {
    let value: number;

    const getter = function() {
      return value;
    };

    const setter = function(newVal: number) {
      if (newVal > max) {
        console.warn(`Value ${newVal} exceeds max ${max}! Setting to max.`);
        value = max;
      } else {
        value = newVal;
      }
    };

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

class GameCharacter {
  @MaxValue(100)
  health: number = 50;
}

const hero = new GameCharacter();
hero.health = 150; // Output: Value 150 exceeds max 100! Setting to max.
console.log(hero.health); // 100
  • Note: This example uses a Decorator Factory (explained below) to pass the max value.

 

Parameter Decorators

A parameter decorator is used to record information about a specific parameter in a method's signature. It receives the target, the method name, and the index of the parameter.

Example: Parameter Decorator

function Required(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`Checking parameter at index ${parameterIndex} in ${propertyKey}...`);
}

class UserService {
  updateUser(@Required id: string, name: string) {
    console.log(`Updating user ${id}`);
  }
}
  • On their own, parameter decorators usually just "mark" data. You typically use them with a method decorator that reads this metadata and performs validation before the method runs.

 

Decorator Factory

If you want to pass custom arguments to your decorator (like a configuration object or a string), you need a Decorator Factory. A factory is simply a function that returns the decorator function itself.

Example: Decorator Factory

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

    descriptor.value = function(...args: any[]) {
      const user = { role: 'guest' }; // Imagine getting this from a session
      if (user.role !== requiredRole) {
        throw new Error("Unauthorized access!");
      }
      return originalMethod.apply(this, args);
    };
  };
}

class AdminPanel {
  @Role('admin')
  deleteUser() {
    console.log("User deleted.");
  }
}
  • The Role('admin') call returns the actual decorator function that TypeScript then applies to the deleteUser method.
Developer Tip: Use factories whenever you need your decorator to be "configurable." It makes your decorators much more reusable across different parts of your application.

 

Summary

  • Decorators are functions that allow you to declaratively add behavior to classes and their members.
  • They help keep your code DRY (Don't Repeat Yourself) by moving repetitive logic (like logging or validation) into reusable decorators.
  • You must enable experimentalDecorators in your tsconfig.json to use them.
  • Class, Method, Property, and Parameter decorators each serve different purposes and receive different arguments.
  • Decorator Factories allow you to pass parameters to your decorators, making them dynamic and highly flexible.
  • They are a staple of modern TypeScript frameworks, especially for building scalable enterprise applications.