TypeScript Decorators: From Basics to Advanced

Decorators let you annotate and modify classes and their members using a concise, reusable syntax. They are widely used for cross-cutting concerns such as logging, dependency injection, validation, and metadata-driven frameworks.

Setup and Compiler Options

Enable decorators and (optionally) metadata emission in your tsconfig.json to use decorator features and libraries that rely on runtime type metadata.

// tsconfig.json (minimal)
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strict": true
  }
}

Note: TypeScript's decorator support has evolved; consult the official TypeScript docs for the current stage and syntax details.

Decorator Types and Simple Examples

Class Decorator

A class decorator receives the constructor and can replace or augment it.

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
class Person {
  constructor(public name: string) {}
}

Method Decorator

Method decorators receive the target, property key, and descriptor — useful for wrapping behavior like logging or memoization.

function log(
  target: Object,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey}`, args);
    return original.apply(this, args);
  };
  return descriptor;
}

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

Property Decorator

Property decorators run at declaration time and can define metadata or replace property behavior via accessors.

function readonly(target: any, key: string) {
  Object.defineProperty(target, key, {
    writable: false
  });
}

class Config {
  @readonly
  version = "1.0.0";
}

Parameter Decorator

Parameter decorators receive the target, method name, and parameter index — commonly used to record metadata for DI or validation.

function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
  const existing: number[] = Reflect.getOwnMetadata("required_params", target, propertyKey) || [];
  existing.push(parameterIndex);
  Reflect.defineMetadata("required_params", existing, target, propertyKey);
}

Decorator Factories and Composition

Decorator factories let you pass arguments to decorators; composition allows stacking multiple decorators cleanly. These patterns are essential for reusable, configurable decorators.

function route(path: string) {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    Reflect.defineMetadata("route", path, target, key);
  };
}

function auth(role: string) {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function (...args: any[]) {
      if (!this.user || this.user.role !== role) throw new Error("Forbidden");
      return original.apply(this, args);
    };
  };
}

class Controller {
  @route("/admin")
  @auth("admin")
  getAdmin() { /* ... */ }
}

Metadata and Reflect

To read type metadata at runtime (for example, parameter types), use reflect-metadata and enable emitDecoratorMetadata. Many frameworks rely on this pattern.

import "reflect-metadata";

class Example {
  method(a: string, b: number) {}
}

const types = Reflect.getMetadata("design:paramtypes", Example.prototype, "method");
console.log(types); // [String, Number]

Advanced Patterns

1. Decorator Composition and Order

Decorators are applied in a specific order: parameter decorators, then method/property, then class decorators. When stacking, be mindful of execution order and side effects.

2. Class Mixins via Decorators

Use class decorators to inject behavior or mixin methods while preserving typing with helper generics.

type Constructor = new (...args: any[]) => T;

function Timestamped(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
  };
}

@((Base: any) => Timestamped(Base))
class Item { name = "x"; }

3. AOP-style Wrappers

Method decorators can implement before/after hooks, retries, or circuit-breaker logic for cross-cutting concerns.

4. Declarative Validation

Property and parameter decorators can collect validation rules that a runtime validator consumes to validate DTOs or API inputs. This pattern is common in frameworks like NestJS.

Testing and Debugging Decorators

Because decorators run at declaration time, unit tests should import modules in a controlled way and reset any global metadata between tests. Use spies around original methods when testing behavior wrappers.

// Example: restore descriptor in afterEach
let originalDescriptor: PropertyDescriptor;

beforeEach(() => {
  originalDescriptor = Object.getOwnPropertyDescriptor(SomeClass.prototype, "method")!;
});

afterEach(() => {
  Object.defineProperty(SomeClass.prototype, "method", originalDescriptor);
});

Best Practices

  • Prefer decorator factories for configurable behavior.
  • Keep decorators small and single-purpose; compose them when needed.
  • Avoid heavy runtime logic in decorators — prefer lightweight wiring and delegate complex work to services.
  • Document side effects clearly (e.g., metadata keys, global state changes).
  • Use TypeScript's official docs and community guides to track syntax changes across versions.

Quick Cheat Sheet

DecoratorSignatureUse
Class(constructor: Function) => void | FunctionAugment or replace constructor
Method(target, key, descriptor) => PropertyDescriptorWrap method behavior
Property(target, key) => voidDefine metadata or accessors
Parameter(target, key, index) => voidRecord parameter metadata