TypeScript Method Decorators

A method decorator is a function that wraps around a class method definition and gets a chance to observe, modify, or completely replace that method before the class is ever instantiated. TypeScript now implements the official, stable ECMAScript decorators proposal (available since TypeScript 5.0), so decorators are no longer an experimental TypeScript-only feature — they run as real JavaScript at class-definition time. Method decorators are how libraries add logging, caching, validation, binding, and other cross-cutting behavior to methods without touching the method’s own body.

This lesson focuses on the modern, standard decorator syntax that ships in current TypeScript without any compiler flag. You will also see how it differs from the older "legacy" decorators (enabled with experimentalDecorators) that frameworks like Angular and NestJS still rely on, so you can recognize both when you encounter them.

Overview / How Method Decorators Work

A method decorator is just a function. When you write @logged above a method, TypeScript calls logged once, at the moment the enclosing class body is evaluated — not once per method call, and not once per instance. The decorator function receives two arguments: the original method (as a plain function value) and a context object describing that method (its name, whether it’s static, private, and so on). Whatever the decorator returns becomes the new method. If it returns nothing (undefined), the original method is left untouched.

This is a crucial mental model: a method decorator is a function-to-function transformer. It takes the method you wrote and hands back the method that will actually live on the class’s prototype. That’s why decorators are so good at wrapping behavior — logging before/after a call, retrying on failure, memoizing results, or auto-binding this — without the class author having to write that boilerplate inside every method.

Because decorators are part of the real JavaScript decorators proposal, the decorator function itself is not erased at runtime — it genuinely executes and can change program behavior. What is erased, as with all TypeScript, are the type annotations: the generic parameters, the ClassMethodDecoratorContext<...> annotation, and so on disappear from the compiled JavaScript. Only the plain function calls and closures remain.

Syntax

The general shape of a method decorator looks like this:

function myDecorator<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
): ((this: This, ...args: Args) => Return) | void {
  // inspect or wrap `target`, then return a replacement (or nothing)
}

class Example {
  @myDecorator
  someMethod(x: number): void {}
}

The parts:

  • target — the original method as a callable function value. Its type parameters (This, Args, Return) let the decorator stay generic and work on any method signature.
  • context — a ClassMethodDecoratorContext object describing the method. Key properties: kind (always the string "method" for method decorators), name (the method’s name, as a string or symbol), static and private (booleans), access (an object with a get method to call the method on a given instance), and addInitializer (a function to register code that runs once per instance, inside the constructor).
  • Return value — return a new function with a compatible signature to replace the method, or return nothing to leave it as-is.
  • @myDecorator — the application syntax, placed directly above the method (no parentheses if the decorator itself takes no configuration arguments).

A decorator factory is a function that returns a decorator, letting you pass configuration: @repeat(3) instead of @repeat. You’ll see one in Example 2 below.

Examples

Example 1: A basic logging decorator

function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  const methodName = String(context.name);

  function replacementMethod(this: This, ...args: Args): Return {
    console.log(`Calling ${methodName} with`, args);
    const result = target.call(this, ...args);
    console.log(`${methodName} returned`, result);
    return result;
  }

  return replacementMethod;
}

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

const calc = new Calculator();
calc.add(2, 3);

Output:

Calling add with [ 2, 3 ]
add returned 5

The decorator receives add as target and returns replacementMethod, which becomes the real add on Calculator.prototype. Every call to calc.add(...) now runs the wrapper, which logs before and after delegating to the original implementation via target.call(this, ...args). Notice the generics: because Args and Return are type parameters, this same decorator works on a method with any parameter list and any return type.

Example 2: A parameterized decorator factory

function repeat(times: number) {
  return function <This, Args extends unknown[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
  ) {
    const methodName = String(context.name);
    function replacementMethod(this: This, ...args: Args): Return {
      let result: Return;
      for (let i = 0; i < times; i++) {
        console.log(`${methodName} run #${i + 1}`);
        result = target.call(this, ...args);
      }
      return result!;
    }
    return replacementMethod;
  };
}

class Greeter {
  @repeat(3)
  greet(name: string): string {
    const message = `Hello, ${name}!`;
    console.log(message);
    return message;
  }
}

const greeter = new Greeter();
greeter.greet("Ada");

Output:

greet run #1
Hello, Ada!
greet run #2
Hello, Ada!
greet run #3
Hello, Ada!

repeat(3) is a decorator factory: calling it with 3 returns the actual decorator function, which closes over times. This pattern — a function that returns a decorator — is how you pass configuration into a decorator at the call site.

Example 3: Auto-binding this with addInitializer

function bound<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  const methodName = context.name;
  context.addInitializer(function (this: This) {
    (this as any)[methodName] = (this as any)[methodName].bind(this);
  });
}

class Button {
  label: string;

  constructor(label: string) {
    this.label = label;
  }

  @bound
  onClick(): void {
    console.log(`${this.label} was clicked`);
  }
}

const button = new Button("Save");
const handler = button.onClick;
handler();

Output:

Save was clicked

Without @bound, extracting button.onClick into a bare variable and calling it would lose its this binding (a classic JavaScript pitfall when passing methods as event handlers). context.addInitializer lets the decorator queue up extra code that runs once per instance, right when the constructor runs — here, replacing the instance’s copy of the method with a version permanently bound to that instance.

Under the Hood

  1. TypeScript parses the class body and finds @decoratorName immediately above a method.
  2. At the point the class declaration is evaluated (not when instances are created), TypeScript calls the decorator function, passing the method and a freshly built context object.
  3. If multiple decorators are stacked on one method, their factory expressions are evaluated top-to-bottom, but the resulting decorator functions are applied bottom-to-top — the closest decorator to the method wraps first.
  4. Whatever the decorator returns replaces the method on the class’s prototype (or the class itself, for static methods). If it returns undefined, the original method is kept.
  5. Any calls registered via context.addInitializer are collected and run later, once per instance, immediately inside that instance’s constructor.
  6. At compile time, all the type annotations (This, Args, Return, the ClassMethodDecoratorContext<...> annotation) are stripped. The emitted JavaScript contains only the plain functions and the real decorator call — the type system’s job is to make sure the types line up before that erasure happens.

Common Mistakes

Mistake 1: Using the old three-argument (legacy) decorator signature

Many older tutorials and libraries show decorators shaped like (target, propertyKey, descriptor). That signature belongs to the legacy, TypeScript-only decorators enabled by the experimentalDecorators compiler option. Written without that flag, against the modern standard, it does not match what a method decorator is expected to look like:

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

class Widget {
  @logged
  render(): void {
    console.log("rendering");
  }
}

Under the standard decorators TypeScript uses today, this reports an error along the lines of "Unable to resolve signature of method decorator when called as an expression" — because logged‘s parameter list (target, propertyKey, descriptor) doesn’t match the expected (target, context) shape. The fix is to rewrite it against the modern API:

function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  const methodName = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`Calling ${methodName}`);
    return target.call(this, ...args);
  };
}

class Widget {
  @logged
  render(): void {
    console.log("rendering");
  }
}

new Widget().render();

Output:

Calling render
rendering

Mistake 2: Hard-coding a non-generic return type

A decorator written for one specific method shape often silently assumes every method returns void, and then breaks the moment someone applies it to a method that returns a value:

function badLogger<This, Args extends unknown[]>(
  target: (this: This, ...args: Args) => void,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => void>
) {
  return function (this: This, ...args: Args): void {
    console.log("called");
    target.call(this, ...args);
  };
}

class Repo {
  @badLogger
  findAll(): string[] {
    return [];
  }
}

Because badLogger‘s target parameter is typed as returning void, and findAll actually returns string[], TypeScript reports that the method’s type is not assignable to the type the decorator expects. The fix is to make the return type generic, exactly as in Example 1, so the decorator adapts to whatever the decorated method returns:

function goodLogger<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  return function (this: This, ...args: Args): Return {
    console.log("called");
    return target.call(this, ...args);
  };
}

class Repo {
  @goodLogger
  findAll(): string[] {
    return ["a", "b"];
  }
}

console.log(new Repo().findAll());

Output:

called
[ 'a', 'b' ]

Best Practices

  • Always write method decorators generically over This, Args, and Return so they work on any method signature, not just the one you tested first.
  • Prefer returning a new function over mutating the original method’s behavior in place — it keeps the decorator’s data flow explicit and easy to reason about.
  • Use context.addInitializer for anything that must happen per-instance (like binding this), and the decorator’s return value for anything that wraps every call.
  • Use String(context.name) when you need a printable label, since context.name can be a symbol as well as a string.
  • Check context.kind if you write a decorator meant to be reused across methods, getters, setters, and fields — each has a different context shape.
  • Know which decorator system a library expects: modern standard decorators need no compiler flag, while Angular and NestJS still use legacy decorators via experimentalDecorators. Don’t mix the two signatures in the same project without knowing which mode is active.
  • Keep decorators small and single-purpose (logging, retrying, binding) so they compose cleanly when stacked on one method.

Practice Exercises

  • Exercise 1: Write a @once method decorator that runs the original method only on the first call; every subsequent call should return the cached result from that first call without re-running the method body.
  • Exercise 2: Write a decorator factory @minArgs(count) that throws an Error if the method is called with fewer than count arguments, and otherwise calls the method normally.
  • Exercise 3: Write a @timed method decorator that logs how many milliseconds a method call took (using Date.now() before and after calling the original method), then returns the method’s normal result unchanged.

Summary

  • A method decorator is a function that receives the original method plus a ClassMethodDecoratorContext, and can return a replacement method.
  • Decorators run once, at class-definition time — not per call and not per instance — except for code registered via context.addInitializer, which runs once per instance in the constructor.
  • Decorator factories are functions that return a decorator, used to pass configuration via @decorator(args).
  • Write decorators generically over This, Args, and Return so they work with any method signature.
  • Decorators are real, non-erased JavaScript behavior; only their type annotations are stripped at compile time.
  • The old three-argument (target, propertyKey, descriptor) shape belongs to legacy decorators (experimentalDecorators) and is a different, incompatible system from the modern standard decorators covered here.