Skip to content
TypeScript ts decorators 4 min read

Method Decorators

A method decorator runs when a class is defined and receives the method it is attached to, letting you observe it, wrap it, or replace it with a new function. This is where cross-cutting concerns like logging, timing, caching, and authorization checks live, because the decorator can return a function that runs before and after the original. The Stage 3 (TypeScript 5.0) standard form passes the method value plus a typed ClassMethodDecoratorContext, while the legacy experimentalDecorators form mutates a property descriptor. This page covers both, plus how to bind methods and read context.

Stage 3 method decorators

A standard method decorator is a function (value, context) where value is the original method and context is a ClassMethodDecoratorContext. Return a new function to replace the method, or return nothing to keep it.

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`→ ${name}(${args.join(", ")})`);
    const result = value.call(this, ...args);
    console.log(`← ${name} returned ${result}`);
    return result;
  };
}

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

new Calculator().add(2, 3); // logs the call and its result

The returned wrapper preserves this via value.call(this, ...args), so the method still behaves like an instance method. The generic This, Args, and Return parameters keep the wrapper fully typed against any method shape.

The method decorator context

ClassMethodDecoratorContext describes the method being decorated. The most useful member is addInitializer, which runs once per instance during construction — perfect for auto-binding a method to its instance.

MemberTypePurpose
kind"method"Discriminates the target
namestring | symbolThe method’s key
staticbooleanWhether the method is static
privatebooleanWhether the method is #private
access{ get }Read the method off an instance
addInitializer(fn) => voidRun per-instance setup logic
function bound(value: Function, context: ClassMethodDecoratorContext) {
  const name = context.name;
  context.addInitializer(function (this: any) {
    this[name] = this[name].bind(this);
  });
}

class Counter {
  count = 0;
  @bound increment() {
    this.count++;
  }
}

const { increment } = new Counter();
increment(); // ✅ 'this' is still the instance

Because addInitializer runs in the constructor with this bound to the instance, the method is rebound before any code can detach it.

A decorator factory: caching

A decorator factory returns a decorator so you can pass configuration. Here a memoize factory wraps the method in a per-instance cache.

function memoize() {
  return function <T extends (...args: any[]) => any>(
    value: T,
    _context: ClassMethodDecoratorContext,
  ): T {
    const cache = new Map<string, any>();
    return function (this: any, ...args: any[]) {
      const key = JSON.stringify(args);
      if (!cache.has(key)) cache.set(key, value.apply(this, args));
      return cache.get(key);
    } as T;
  };
}

class Fib {
  @memoize()
  compute(n: number): number {
    return n < 2 ? n : this.compute(n - 1) + this.compute(n - 2);
  }
}

The cache lives in the decorator’s closure, shared across calls to the same method. Casting back to T keeps the public signature identical to the original method.

Legacy method decorators

Under experimentalDecorators, a method decorator receives (target, propertyKey, descriptor) and edits the PropertyDescriptor in place. Reassigning descriptor.value replaces the method.

// tsconfig: "experimentalDecorators": true
function Log(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor,
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey}`);
    return original.apply(this, args);
  };
}

class Service {
  @Log fetch(id: number) {
    return { id };
  }
}

The legacy signature has three positional arguments and no context object — that is the quickest way to tell the two systems apart. NestJS interceptors and route handlers rely on this older flavor together with reflect-metadata.

Best Practices

  • Use value.call(this, ...) or apply inside wrappers so the original this is preserved.
  • Reach for addInitializer to auto-bind methods instead of binding in the constructor by hand.
  • Keep wrappers transparent: return a function with the same signature so callers see no difference.
  • Use decorator factories when behavior needs configuration, like a cache TTL or log level.
  • Store per-method state (caches, counters) in the decorator’s closure, not on the instance.
  • Do not mix Stage 3 and legacy decorators in one project; their signatures are incompatible.
  • Avoid heavy work in wrappers on hot paths — measure before adding logging or memoization.
Last updated June 29, 2026
Was this helpful?