Decorators in TypeScript are experimental, meaning their syntax and behavior can change, and they aren’t enabled by default.

Let’s see a class decorator in action. Imagine you want to automatically register every class that extends a base Service class into some kind of registry.

function ServiceRegistry(target: Function) {
  console.log(`Registering service: ${target.name}`);
  // In a real app, you'd add target to a global registry here
}

@ServiceRegistry
class UserService {
  constructor() {
    console.log("UserService instance created");
  }
}

@ServiceRegistry
class ProductService {
  constructor() {
    console.log("ProductService instance created");
  }
}

// When this code runs, you'll see:
// Registering service: UserService
// Registering service: ProductService
// UserService instance created
// ProductService instance created

The ServiceRegistry function is our decorator. It gets executed when the class definition is evaluated, not when an instance is created. The target argument is the constructor function of the class itself. This allows us to inspect, modify, or even replace the class definition.

Now, let’s look at method decorators. Suppose you want to log every time a specific method is called, along with its arguments and return value.

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

  descriptor.value = function(...args: any[]) {
    console.log(`Calling method "${propertyKey}" with arguments:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`Method "${propertyKey}" returned:`, result);
    return result;
  };

  return descriptor;
}

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

  @LogMethodCall
  subtract(a: number, b: number): number {
    return a - b;
  }
}

const calc = new Calculator();
calc.add(5, 3);
calc.subtract(10, 4);

// Output:
// Calling method "add" with arguments: [ 5, 3 ]
// Method "add" returned: 8
// Calling method "subtract" with arguments: [ 10, 4 ]
// Method "subtract" returned: 6

Here, LogMethodCall receives the class prototype (target), the method name (propertyKey), and a PropertyDescriptor which contains the method’s implementation (descriptor.value). We can wrap the originalMethod with our logging logic. The descriptor.value is then replaced with our new function, effectively augmenting the original method’s behavior.

Property decorators are a bit more subtle. They are useful for initializing or validating properties. Let’s say you want to ensure a property is always initialized to a default value if it’s not explicitly set.

function DefaultValue(defaultValue: any) {
  return function (target: any, propertyKey: string) {
    Object.defineProperty(target, propertyKey, {
      get: function() {
        return defaultValue;
      },
      enumerable: true,
      configurable: true,
    });
  };
}

class Config {
  @DefaultValue('development')
  environment: string;

  @DefaultValue(8080)
  port: number;
}

const appConfig = new Config();
console.log(appConfig.environment); // Output: development
console.log(appConfig.port);       // Output: 8080

The DefaultValue decorator factory creates a decorator function. This decorator is applied to environment and port. Inside the decorator, Object.defineProperty is used to define a getter for the property. This getter always returns the defaultValue we provided. Crucially, this definition happens on the prototype of the Config class. When you access appConfig.environment, you’re actually invoking this getter defined on Config.prototype.

One detail often overlooked is that decorators are executed in a specific order. For class decorators, they run before any method or property decorators within that class. When you have multiple decorators on a single member (method or property), they are applied in a top-down order, but the results are applied bottom-up. So, if you have @decoratorA on top of @decoratorB on a method, decoratorB’s implementation is wrapped by decoratorA’s.

The primary problem decorators solve is reducing boilerplate code for cross-cutting concerns like logging, validation, access control, or dependency injection. They allow you to declaratively add behavior to classes, methods, and properties without cluttering your core business logic. The real power comes from decorator factories, which are functions that return decorators, allowing you to pass arguments to your decorators, as seen with @DefaultValue('development').

The next frontier to explore is how decorators interact with parameter types and how to use them for more advanced metaprogramming tasks like aspect-oriented programming.

Want structured learning?

Take the full Typescript course →