TypeScript Decorators: Meta-programming and Code Enhancement

July 25, 2025

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee

When building TypeScript applications, code often gets cluttered with repetitive patterns: logging method calls, validating inputs, measuring performance, and checking user permissions. Every method starts to look like this:

class ArticleService {
  async createArticle(data: ArticleData, userId: string): Promise<Article> {
    // Repetitive boilerplate: performance timing
    const startTime = Date.now();

    // Actual business logic
    const article = await this.repository.save({
      ...data,
      id: crypto.randomUUID(),
      authorId: userId,
      createdAt: new Date(),
    });

    // Repetitive boilerplate: performance logging
    logger.info(`createArticle executed in ${Date.now() - startTime}ms`);
    return article;
  }
}

This gets frustrating quickly. The actual business logic is buried under repetitive performance measurement code. Every method follows the same pattern: start timing, execute logic, log performance. Changing the logging format or adding more metrics requires updating dozens of methods.

In our previous article about modules and namespaces, we explored how to organize TypeScript code as it grows. Now we're ready to tackle another aspect of scaling applications: eliminating repetitive cross-cutting concerns using decorators.

TypeScript decorators provide a clean way to add functionality to classes, methods, properties, and parameters without cluttering your core business logic. They separate concerns like performance monitoring from the actual work the code needs to do.

What Decorators Really Are

Before we dive into the syntax, it's important to understand what decorators actually do. Despite the special @ syntax that makes them look magical, decorators are just functions that wrap other functions. When you write:

class ArticleService {
  @measurePerformance
  createArticle(data: ArticleData): Article {
    return { ...data, id: crypto.randomUUID() };
  }
}

The decorator receives information about what it's decorating and can modify or replace it. In the case of method decorators, they typically wrap the original method with additional functionality while preserving the original behavior.

Understanding when decorators actually run is crucial. When you write @measurePerformance, that decorator function executes immediately as the class loads, not when someone calls createArticle(). The decorator sets up a wrapper function that replaces the original method. Every subsequent call to createArticle() actually invokes this wrapper.

Building Your First Decorator

A simple performance measurement decorator demonstrates how this works in practice. Building it step by step reveals each piece:

function measurePerformance(
  target: Record<string, unknown>,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: unknown[]) {
    const startTime = Date.now();
    const result = originalMethod.apply(this, args);
    const endTime = Date.now();

    logger.info(`${propertyKey} executed in ${endTime - startTime}ms`);
    return result;
  };

  return descriptor;
}

Let's break down what each parameter does:

  • target is the prototype of the class (for instance methods) or the constructor function (for static methods)
  • propertyKey is the name of the method being decorated (in our case, "createArticle")
  • descriptor is the property descriptor for the method, which contains the actual function in its value property

The decorator saves a reference to the original method, then replaces descriptor.value with a new function that wraps the original. When someone calls the decorated method, they're actually calling our wrapper function, which measures the time and then calls the original method.

Now we can use this decorator on our ArticleService:

class ArticleService {
  @measurePerformance
  createArticle(data: ArticleData): Article {
    return {
      id: crypto.randomUUID(),
      title: data.title,
      content: data.content,
      authorId: data.authorId,
      createdAt: new Date(),
      tags: data.tags || [],
    };
  }
}

Now when you call service.createArticle(articleData), the console automatically shows "createArticle executed in 2ms" without any timing code cluttering the method itself. The decorator handles all the performance measurement invisibly.

Enabling Decorators in TypeScript

Before you can use decorators like the one we just built, you need to configure TypeScript to support them. Using decorators in TypeScript projects requires enabling them in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

The experimentalDecorators flag enables the current decorator syntax, while emitDecoratorMetadata is needed if you're using decorators that rely on runtime type information (common in frameworks like Angular).

Be aware that decorators are still an experimental feature in TypeScript. The syntax and behavior may change in future versions as the JavaScript decorator proposal evolves.

When to Use Decorators

Now that you've seen how to build and configure decorators, the question becomes: when should you actually use them? Our performance measurement decorator solved the exact problem we started with: eliminating repetitive timing code from our ArticleService. But decorators aren't the right solution for every situation.

Decorators work best when you have the same pattern repeated across multiple methods. In our example, we had the same sequence everywhere: start timer, execute business logic, log performance. This repetition signals that a decorator might help.

Other common patterns that benefit from decorators include:

  • Authentication checks that verify user permissions before method execution
  • Caching logic that stores and retrieves results based on method parameters
  • Request validation that checks input parameters before processing
  • Error handling that wraps methods with consistent logging and recovery

These are called cross-cutting concerns because they affect multiple parts of your application in the same way. The key requirement is that the decorator functionality stays separate from your core business logic.

Decorators also work well for framework integration. Angular's dependency injection uses @Injectable() to mark classes for automatic dependency resolution. Instead of manually configuring each dependency, the decorator provides a clean interface to the framework.

On the other hands, don't use decorators when the functionality is specific to one method. Just write the code inline. Don't use them when the logic is complex enough that understanding the method requires knowing exactly how multiple decorators interact.

The practical test is straightforward: do decorators make your code easier to read and maintain? They should eliminate repetition while keeping the business logic clear. If they accomplish that, they're probably the right choice.

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee