Skip to main content
TypeScript advanced Lesson 12 of 21

Decorators in TypeScript

Use class, method, property, and parameter decorators to add reusable cross-cutting behavior with TypeScript's decorator system.

What Are Decorators?

Cross-cutting concerns — logging, timing, validation, caching, retry logic — tend to end up duplicated across many methods and classes. Decorators solve this by letting you express those behaviors as reusable annotations that attach to a class, method, property, or parameter at definition time. The decorated code stays clean and focused on its core responsibility, while the decorator handles the surrounding infrastructure. Frameworks like NestJS and Angular are built almost entirely on this pattern.

// A simple method decorator that logs every call
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey} with`, args);
    const result = original.apply(this, args);
    console.log(`${propertyKey} returned`, result);
    return result;
  };
  return descriptor;
}

class Calculator {
  @log  // attach the decorator — no changes to the method body needed
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5

Enable legacy decorators in tsconfig.json:

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

Class Decorators

Class decorators receive the constructor function and can wrap or replace it entirely. This makes them the right tool for behaviors that apply to the class as a whole — sealing it against modification, enforcing singleton instantiation, or registering it in a container. The decorator runs once when the class is defined, not on each instantiation.

// Prevents adding new properties to the class or its prototype
function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

// Wraps the constructor so only one instance is ever created
function singleton<T extends { new(...args: any[]): {} }>(Base: T) {
  let instance: InstanceType<T> | null = null;
  return class extends Base {
    constructor(...args: any[]) {
      if (instance) return instance; // return the existing instance
      super(...args);
      instance = this as any;
    }
  };
}

@singleton
class DatabasePool {
  private connections: number = 0;

  acquire() {
    this.connections++;
    return this.connections;
  }
}

const pool1 = new DatabasePool();
const pool2 = new DatabasePool();
pool1 === pool2; // true — same instance returned both times

Method Decorators

Method decorators receive target, propertyKey, and descriptor — the property descriptor of the method. By replacing descriptor.value, you wrap the method with new behavior while keeping the original accessible. Decorators can be stacked: they apply bottom-up, so @retry runs first (innermost), then @measure wraps the already-retried function.

// Measures how long the method takes and logs it
function measure(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const original = descriptor.value;
  descriptor.value = async function (...args: any[]) {
    const start = performance.now();
    try {
      return await original.apply(this, args);
    } finally {
      const ms = (performance.now() - start).toFixed(2);
      console.log(`${propertyKey} took ${ms}ms`);
    }
  };
  return descriptor;
}

// Retries the method up to `times` times before giving up
function retry(times: number, delayMs: number = 0) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ): PropertyDescriptor {
    const original = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      for (let attempt = 1; attempt <= times; attempt++) {
        try {
          return await original.apply(this, args);
        } catch (err) {
          if (attempt === times) throw err; // rethrow on final attempt
          console.warn(`Attempt ${attempt} failed, retrying...`);
          if (delayMs > 0) {
            await new Promise((r) => setTimeout(r, delayMs));
          }
        }
      }
    };
    return descriptor;
  };
}

class UserService {
  @measure          // outer: logs total time including retries
  @retry(3, 500)    // inner: retries up to 3 times with 500ms delay
  async fetchUser(id: number): Promise<User> {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
}

Property Decorators

Property decorators receive target and propertyKey but don’t have direct access to the value. To intercept get and set, you use Object.defineProperty to install an accessor on the prototype. This is the mechanism behind many validation libraries — decorators mark the constraint, and a separate validate() call reads the metadata and checks each property.

// Throws if the property is set to null or undefined
function required(target: any, propertyKey: string) {
  let value: any;
  Object.defineProperty(target, propertyKey, {
    get() { return value; },
    set(newValue: any) {
      if (newValue === null || newValue === undefined) {
        throw new Error(`${propertyKey} is required`);
      }
      value = newValue;
    },
    enumerable: true,
    configurable: true,
  });
}

// Throws if the number is outside [min, max]
function range(min: number, max: number) {
  return function (target: any, propertyKey: string) {
    let value: number;
    Object.defineProperty(target, propertyKey, {
      get() { return value; },
      set(newValue: number) {
        if (newValue < min || newValue > max) {
          throw new RangeError(`${propertyKey} must be between ${min} and ${max}`);
        }
        value = newValue;
      },
      enumerable: true,
      configurable: true,
    });
  };
}

class Product {
  @required
  name: string = "";

  @range(0, 10000)
  price: number = 0;
}

const p = new Product();
p.price = 99.99;   // fine — within range
p.price = 99999;   // RangeError: price must be between 0 and 10000

Parameter Decorators

Parameter decorators mark individual constructor or method parameters with metadata. They don’t do anything on their own — they store information (typically via Reflect.metadata) that other decorators or framework code read later. This is the foundation of dependency injection systems like NestJS’s, where @inject("DatabaseService") marks which token to resolve for a parameter.

const INJECT_METADATA_KEY = "inject:params";

// Stores a DI token for the given parameter index
function inject(token: string) {
  return function (target: any, propertyKey: string | symbol | undefined, parameterIndex: number) {
    const existing = Reflect.getMetadata(INJECT_METADATA_KEY, target, propertyKey!) ?? [];
    existing[parameterIndex] = token;
    Reflect.defineMetadata(INJECT_METADATA_KEY, existing, target, propertyKey!);
  };
}

This pattern is the foundation of NestJS’s dependency injection system.

A Practical Validation System

Combining property decorators with a validateObject function gives you a declarative validation system similar to class-validator. Each decorator registers a validation rule in metadata, and validateObject reads that metadata and runs all rules against the current property values. Adding a new rule means writing one decorator function — the rest of the infrastructure is reused.

type Validator = (value: any) => string | null;
const VALIDATORS_KEY = "validators";

// Registers a validator function for a property
function validate(validator: Validator) {
  return function (target: any, propertyKey: string) {
    const validators: Record<string, Validator[]> =
      Reflect.getMetadata(VALIDATORS_KEY, target) ?? {};
    validators[propertyKey] = [...(validators[propertyKey] ?? []), validator];
    Reflect.defineMetadata(VALIDATORS_KEY, validators, target);
  };
}

// Specific validator decorators built on top of validate()
function IsString(target: any, key: string) {
  validate((v) => typeof v === "string" ? null : `${key} must be a string`)(target, key);
}

function MinLength(min: number) {
  return validate((v) => typeof v === "string" && v.length >= min
    ? null
    : `Must be at least ${min} characters`);
}

function IsEmail(target: any, key: string) {
  validate((v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
    ? null
    : `${key} must be a valid email`)(target, key);
}

// DTO with declarative validation rules
class CreateUserDto {
  @IsString
  @MinLength(2)
  name: string = "";

  @IsEmail
  email: string = "";
}

// Reads metadata and runs all registered validators
function validateObject(obj: any): string[] {
  const validators: Record<string, Validator[]> =
    Reflect.getMetadata(VALIDATORS_KEY, obj) ?? {};
  const errors: string[] = [];
  for (const [key, fns] of Object.entries(validators)) {
    for (const fn of fns) {
      const error = fn((obj as any)[key]);
      if (error) errors.push(error);
    }
  }
  return errors;
}

TC39 Stage 3 Decorators (TypeScript 5.0+)

TypeScript 5.0 implemented the TC39 Stage 3 decorator proposal, which is the standard going forward. The new API is cleaner, more composable, and doesn’t require experimentalDecorators — it works out of the box. The key difference is the context object, which replaces the target/propertyKey/descriptor triplet and provides a richer, more structured API including addInitializer for post-construction setup.

// Class decorator using the new context API
function logged<T extends new (...args: any[]) => {}>(Base: T, ctx: ClassDecoratorContext) {
  // addInitializer runs after each instance is constructed
  ctx.addInitializer(function (this: InstanceType<T>) {
    console.log(`Created instance of ${ctx.name}`);
  });
  return Base;
}

// Method decorator that binds the method to the instance automatically
// Solves the classic "lost this" problem when passing methods as callbacks
function bound(
  originalMethod: Function,
  ctx: ClassMethodDecoratorContext
) {
  ctx.addInitializer(function (this: any) {
    // Replace the method on the instance with a bound version
    this[ctx.name] = originalMethod.bind(this);
  });
}

class EventHandler {
  private count = 0;

  @bound
  handleClick() {
    this.count++;
    console.log(this.count);
  }
}

const handler = new EventHandler();
const fn = handler.handleClick; // detached — would lose `this` without @bound
fn(); // still works — bound in the initializer

The new API is more composable and avoids the prototype mutation issues of legacy decorators.

Frequently Asked Questions

Are decorators stable in TypeScript?
TypeScript 5.0 implemented the TC39 Stage 3 decorator proposal, which is the standard going forward. The older experimentalDecorators flag uses a different API. NestJS and other frameworks still use the legacy decorators, so check your framework's docs.
Do I need to enable anything to use decorators?
For legacy decorators (used by Angular, NestJS): set experimentalDecorators: true in tsconfig.json. For TC39 Stage 3 decorators (TypeScript 5.0+): no flag needed, they work by default.
Can decorators change a method's return type?
In the standard decorator proposal, decorators cannot change the types of what they decorate — they are transparent to the type system. You can change runtime behavior but TypeScript still sees the original type.