TypeScript Decorators Introduction

A decorator is a special kind of declaration you attach to a class, method, accessor, or field using the @expression syntax. Decorators let you observe, wrap, or completely replace the thing they decorate at the moment the class is defined, without touching the class’s own source code. They’re how libraries like Angular, NestJS, and TypeORM add behavior — registering a component, wiring up dependency injection, mapping a database column — on top of plain classes, and TypeScript 5.0 made a standardized version of them a core part of the language.

Overview / How it works

Structurally, a decorator is just a function. When the compiler sees @myDecorator above a class member, it evaluates myDecorator and calls it with information about the thing being decorated. Depending on what you return, the class member is left alone, wrapped, or swapped out entirely. This all happens once, when the class body is evaluated — not once per instance. If you decorate a method, the decorator function runs a single time (when the class is defined) to produce the final method; that final method is what every instance then shares on the prototype.

Since TypeScript 5.0, decorators implement the TC39 Stage 3 ECMAScript decorators proposal by default — you do not need to set experimentalDecorators in tsconfig.json to use them, and the ambient types that describe them (ClassDecoratorContext, ClassMethodDecoratorContext, and friends) are built into the compiler automatically. This is a different, incompatible system from the older "legacy" decorators (enabled via experimentalDecorators: true) that frameworks like Angular and NestJS still use for historical reasons — the two have different call signatures, so code written for one will not type-check against the other. This lesson covers the modern, standard decorators that ship by default.

Every decorator receives two things: the target (the class, method, or accessor being decorated) and a context object describing it — its kind ("class", "method", "getter", "setter", "accessor", or "field"), its name, and whether it’s static or private. The decorator can return a replacement value (a new class, a new method, a new { get, set } pair) or return nothing (void) to leave the member as-is.

Syntax

The general shapes look like this:

@decorator
class MyClass {
  @decorator
  method() {}

  @decorator
  accessor field: string;

  @decorator
  plainField: number;
}
  • @decorator — a bare decorator; decorator must evaluate to a function matching the expected shape for whatever it decorates.
  • @decorator(args) — a decorator factory: a function call that itself returns the actual decorator function. Use this whenever the decorator needs configuration.
  • accessor field: Type; — the accessor keyword turns a field into an auto-generated getter/setter pair backed by a private slot, which is what lets a decorator intercept reads and writes.
Decorates context.kind Target parameter type Can return
Class "class" the class constructor a new constructor, or nothing
Method "method" the method function a replacement function, or nothing
Getter / Setter "getter" / "setter" the getter/setter function a replacement function, or nothing
Auto-accessor "accessor" { get, set } object a new { get, set } object, or nothing
Field "field" (none — use context.addInitializer) an initializer function, or nothing

Examples

Example 1: A class decorator that logs construction

Class decorators receive the constructor itself. Because the standard allows a class decorator to return a new class, you can subclass the original to add behavior around every instantiation:

function loggedClass<Class extends new (...args: any[]) => any>(
  target: Class,
  context: ClassDecoratorContext
) {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      console.log(
        `constructing an instance of ${target.name} with arguments ${args.join(", ")}`
      );
    }
  };
}

@loggedClass
class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const p = new Person("Ray");

Output:

constructing an instance of Person with arguments Ray

loggedClass runs exactly once, when TypeScript evaluates the class Person declaration. It returns a brand-new anonymous class that extends Person, and that returned class becomes the actual value bound to the name Person. Every subsequent new Person(...) call goes through the decorated constructor.

Example 2: A method decorator that wraps a function

Method decorators receive the method itself as a plain function and must return a replacement function with a matching signature:

function loggedMethod<This, Args extends any[], 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(`LOG: Entering method '${methodName}'.`);
    const result = target.call(this, ...args);
    console.log(`LOG: Exiting method '${methodName}'.`);
    return result;
  }

  return replacementMethod;
}

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  @loggedMethod
  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const p = new Person("Ray");
p.greet();

Output:

LOG: Entering method 'greet'.
Hello, my name is Ray.
LOG: Exiting method 'greet'.

The generic parameters This, Args, and Return let loggedMethod apply to a method of any signature while still type-checking the wrapped call — target.call(this, ...args) is fully typed, and the replacement method’s signature must match the original exactly.

Example 3: An accessor decorator that logs reads and writes

The accessor keyword turns a field into a getter/setter pair, which an accessor decorator can intercept:

function logged<This, Value>(
  target: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
  const name = String(context.name);
  return {
    get(this: This): Value {
      const value = target.get.call(this);
      console.log(`getting ${name} => ${String(value)}`);
      return value;
    },
    set(this: This, newValue: Value): void {
      console.log(`setting ${name} => ${String(newValue)}`);
      target.set.call(this, newValue);
    },
  };
}

class Person {
  @logged accessor name: string;

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

const person = new Person("Ada");
person.name = "Grace";
console.log(person.name);

Output:

setting name => Ada
setting name => Grace
getting name => Grace
Grace

The constructor’s this.name = name already goes through the decorated setter, which is why "setting name => Ada" appears before any explicit assignment outside the constructor.

Under the hood

A few things are worth internalizing about how decorators actually execute:

Evaluation order. Decorator expressions on a single class are evaluated top-to-bottom in source order, but the resulting functions are applied bottom-to-top (innermost/closest to the member first) — the same "onion" ordering used for function composition. With a single decorator per member, as in the examples above, you don’t need to worry about this, but it matters once you stack multiple decorators on the same method.

Per-class, not per-instance. The decorator function itself runs once, at class-definition time. Any behavior that needs to happen for every instance has to live inside the function or object the decorator returns (the replacement method, or the get/set functions), or be registered via context.addInitializer, which schedules a callback to run once per instance during construction.

Types are fully erased at runtime. After compilation, none of the type annotations — This, Args, ClassMethodDecoratorContext<...>, and so on — exist in the emitted JavaScript. What remains is plain function calls: the compiled output calls your decorator function with the real method and a real (runtime) context object, and substitutes whatever it returns. The type system’s only job is to make sure those calls line up correctly before you ever run the code.

Common Mistakes

Mistake 1: Applying a decorator to the wrong kind of member

A decorator’s parameter types are tied to a specific context.kind. Writing a method decorator and attaching it to a plain field mismatches both the target type (a function vs. a field’s value) and the context type:

function loggedMethod<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  return target;
}

class Counter {
  @loggedMethod
  count: number = 0;
}

Here count is a field, so TypeScript passes a ClassFieldDecoratorContext and a value of type number — neither matches the function-shaped target or the ClassMethodDecoratorContext that loggedMethod expects. tsc reports a type error along the lines of "this context type is not assignable", because a field decorator and a method decorator are simply not interchangeable. The fix is to decorate an actual method, or write a decorator whose context type is generic over context.kind if it truly needs to support both.

Mistake 2: Forgetting to call a decorator factory

A decorator factory is a function that returns a decorator — it must be called with parentheses before the @ syntax can use it:

function minLength(min: number) {
  return function <This, Value extends string>(
    target: ClassAccessorDecoratorTarget<This, Value>,
    context: ClassAccessorDecoratorContext<This, Value>
  ): ClassAccessorDecoratorResult<This, Value> {
    return {
      get(this: This): Value {
        return target.get.call(this);
      },
      set(this: This, value: Value): void {
        if (value.length < min) {
          throw new RangeError(`must be at least ${min} characters`);
        }
        target.set.call(this, value);
      },
    };
  };
}

class Account {
  @minLength accessor username: string;

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

Written this way, @minLength treats minLength itself — a (min: number) => ... function — as the decorator, so TypeScript tries to pass the accessor’s target where a number is expected and reports a type error. The fix is to call the factory with its arguments so the result of that call becomes the decorator:

function minLength(min: number) {
  return function <This, Value extends string>(
    target: ClassAccessorDecoratorTarget<This, Value>,
    context: ClassAccessorDecoratorContext<This, Value>
  ): ClassAccessorDecoratorResult<This, Value> {
    return {
      get(this: This): Value {
        return target.get.call(this);
      },
      set(this: This, value: Value): void {
        if (value.length < min) {
          throw new RangeError(`must be at least ${min} characters`);
        }
        target.set.call(this, value);
      },
    };
  };
}

class Account {
  @minLength(3) accessor username: string;

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

const account = new Account("bob");
console.log(account.username);

Output:

bob

Now minLength(3) is evaluated first and its return value — the actual accessor decorator — is what gets applied to username.

Best Practices

  • Prefer the modern (stage 3) decorator syntax for new code — it needs no experimentalDecorators flag and is what the type system checks by default.
  • Before adding decorators to a project that already uses Angular, NestJS, TypeORM, or a similar framework, check whether it relies on legacy (experimentalDecorators) decorators — the two systems have incompatible signatures and cannot be mixed on the same declaration.
  • Type your decorators with generics (This, Args, Return, Value) instead of any so callers still get accurate type-checking and autocomplete on the decorated member.
  • Keep the decorator function itself free of per-instance side effects; push per-instance work into the returned method, the returned get/set functions, or context.addInitializer.
  • Remember a decorator runs once per class definition, not once per instance — don’t reach for a decorator when you really just need constructor logic.
  • Use a decorator factory (a function returning a decorator) any time the decorator needs configuration, and always call it with parentheses, even for zero arguments, e.g. @retry().

Practice Exercises

  • Write a class decorator named frozen that calls Object.freeze on both the class’s constructor and its prototype right after the class is defined. Apply it to a small Config class with a couple of properties.
  • Write a method decorator named once that wraps a method so that only its first call actually runs the original method body; every later call should return the cached result from that first call instead of re-running the body.
  • Write an accessor decorator factory named clamp(min: number, max: number) that decorates a numeric accessor field so that any value set outside [min, max] is clamped into range before being stored.

Summary

  • Decorators are functions invoked once, at class-definition time, that can observe, wrap, or replace a class, method, accessor, or field.
  • TypeScript 5.0+ implements the TC39 stage-3 decorators proposal by default — no experimentalDecorators flag is needed, and this is a different system from the older "legacy" decorators used by frameworks like Angular.
  • Every decorator receives a target (constructor, method, or { get, set } pair) and a context object describing kind, name, static, and private.
  • A decorator can return a replacement value to swap in new behavior, or return nothing to leave the member unchanged.
  • Decorator factories (functions that return a decorator) are how you pass configuration, and must always be called with parentheses.
  • All decorator types are erased at compile time — the emitted JavaScript is just ordinary function calls and substitutions.