TypeScript Method Decorators

Method decorators in TypeScript are a powerful feature of metaprogramming. They allow you to "wrap" a method with additional logic without actually touching the code inside that method. Think of them as reusable wrappers that can handle repetitive tasks like logging, security checks, or performance profiling across your entire application. By using decorators, you can keep your business logic clean and separated from "cross-cutting concerns" like error handling or analytics.

Developer Tip: Method decorators are a staple in modern frameworks like Angular and NestJS. Mastering them will help you understand how these frameworks handle things like routing, dependency injection, and request validation under the hood.

 

What is a Method Decorator?

A method decorator is a simple JavaScript function that is executed at runtime. When you attach it to a method, TypeScript passes that method's details to your decorator function. This gives you the power to inspect the method, change how it works, or even replace it entirely.

A method decorator receives three specific arguments:

  1. Target: For an instance method, this is the prototype of the class. For a static method, it is the constructor function itself.
  2. PropertyKey: A string (or symbol) containing the name of the method being decorated.
  3. Descriptor: The PropertyDescriptor for the method. This is the most important part, as it contains the actual function logic in its .value property.
Best Practice: Always use descriptive names for your decorators. Instead of @DoStuff, use @LogActivity or @ValidateUserRole so other developers immediately understand the decorator's intent.

Method decorators are prefixed with the @ symbol and are placed directly above the method they are meant to modify.

 

Enabling Method Decorators in TypeScript

By default, decorators are considered an "experimental" feature in TypeScript. To use them, you must explicitly tell the TypeScript compiler to allow them in your configuration file.

Example: Enabling Method Decorators

{
  "compilerOptions": {
    "target": "ES6",
    "experimentalDecorators": true
  }
}
  • The experimentalDecorators option must be set to true. While decorators are becoming a standard part of JavaScript, TypeScript's current implementation follows an earlier proposal that is still widely used in the industry.
Watch Out: If you forget to enable this in your tsconfig.json, you will see the error: "Experimental support for decorators is a feature that is subject to change in a future release."

Syntax of Method Decorators

When you define a decorator, the signature looks like this:

function MyDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // Logic goes here
  console.log("Decorating:", propertyKey);
}

 

Applying Method Decorators

To apply a decorator, you simply "tag" the method. This is useful for simple tasks where you don't need to pass any configuration to the decorator.

Example: Applying a Simple Method Decorator

function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log(`The method ${propertyKey} has been decorated.`);
}

class User {
  @Log
  save() {
    console.log("Saving user to database...");
  }
}

const user = new User();
user.save();
  • In this example, the @Log decorator runs as soon as the class is defined, not just when the method is called. This is a common point of confusion for beginners.
Common Mistake: Thinking the decorator runs only when the method is called. Actually, the decorator function executes once when the class is loaded to "setup" the modification.

 

Modifying the Method Behavior

To change how a method behaves when it is called, you need to modify the descriptor.value. This is where you can intercept arguments and the return value.

Example: Modifying the Method

function Debug(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value; // Save the original function
  
  // Replace the original function with a new one
  descriptor.value = function (...args: any[]) {
    console.log(`[DEBUG] Calling ${propertyKey} with:`, JSON.stringify(args));
    
    // Call the original method and capture the result
    const result = originalMethod.apply(this, args); 
    
    console.log(`[DEBUG] ${propertyKey} returned:`, result);
    return result; // Return the original result so the program continues normally
  };
}

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

const calc = new Calculator();
calc.add(5, 10); 
// Output: 
// [DEBUG] Calling add with: [5,10]
// [DEBUG] add returned: 15
  • The use of .apply(this, args) is crucial. It ensures that the this context inside your method still points to the class instance.

 

Accessing and Modifying Method Properties

The PropertyDescriptor isn't just for the function code. It also controls how the property behaves in the JavaScript environment (whether it can be changed, deleted, or seen in loops).

Example: Read-Only Method

function ReadOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  descriptor.writable = false;
}

class APIClient {
  @ReadOnly
  connect() {
    console.log("Connecting to secure server...");
  }
}

const client = new APIClient();
// This will fail or throw an error in strict mode
client.connect = () => console.log("Hacked!"); 
  • Setting writable = false prevents any other part of your code from overwriting this method at runtime. This is great for security-critical methods.

 

Method Decorators with Parameters

Sometimes you want your decorator to be configurable. To do this, you create a Decorator Factory. This is a function that returns the actual decorator function.

Example: Method Decorator with Parameters

function Authorize(role: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function (...args: any[]) {
      const userRole = "guest"; // Imagine fetching this from a session
      if (userRole !== role) {
        console.error(`Access Denied! Role '${role}' required.`);
        return;
      }
      return originalMethod.apply(this, args);
    };
  };
}

class AdminPanel {
  @Authorize("admin")
  deleteUser(id: number) {
    console.log(`User ${id} deleted.`);
  }
}

const panel = new AdminPanel();
panel.deleteUser(99); // Output: Access Denied! Role 'admin' required.
Developer Tip: Decorator factories allow you to reuse the same logic with different settings. You could use @Authorize("admin") on one method and @Authorize("editor") on another.

 

Using Method Decorators for Validation

Validation is one of the most practical uses for decorators. You can intercept the arguments passed to a method and stop execution if the data is invalid.

Example: Method Decorator for Validation

function MinimumValue(min: number) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const amount = args[0]; // Check the first argument
      if (amount < min) {
        throw new Error(`Transaction failed: Minimum amount is ${min}`);
      }
      return originalMethod.apply(this, args);
    };
  };
}

class BankAccount {
  balance = 1000;

  @MinimumValue(100)
  withdraw(amount: number) {
    this.balance -= amount;
    console.log(`Withdrew ${amount}. New balance: ${this.balance}`);
  }
}

const myAcc = new BankAccount();
myAcc.withdraw(500); // Works fine
myAcc.withdraw(10);  // Throws Error: Transaction failed: Minimum amount is 100
  • In this real-world scenario, the business logic (subtracting money) stays separate from the validation logic (checking the minimum amount).
Best Practice: When using decorators for validation, consider throwing errors rather than just logging messages. This ensures that the calling code knows the operation failed.

 

Summary

  • Method decorators allow you to attach metadata or modify the behavior of class methods in a clean, declarative way.
  • They provide access to the target (prototype), propertyKey (name), and descriptor (the function itself).
  • To change a method's logic, you must wrap the descriptor.value and use .apply(this, args) to maintain the correct context.
  • Decorator Factories (functions that return a decorator) are necessary if you need to pass custom parameters to your decorator.
  • Common use cases include logging, authentication, validation, and performance monitoring.