TypeScript Class Decorators

A class decorator is a function you attach to a class declaration with @decoratorName syntax. It runs exactly once, at the moment the class itself is defined (not when instances are created), and it can inspect the class, register it somewhere, or even replace it entirely with a new class. Class decorators are how TypeScript lets you add cross-cutting behavior — logging, validation, sealing, registration, dependency injection — without cluttering the class body itself.

This lesson focuses on the modern, standards-based decorators that TypeScript 5.0+ supports natively, with no extra compiler flags required.

Overview: How Class Decorators Work

Historically, TypeScript shipped an experimental decorator implementation (enabled via the experimentalDecorators compiler option) based on an early TC39 proposal. That flavor is still used by frameworks like Angular and NestJS together with the reflect-metadata package. Since TypeScript 5.0, the compiler also implements the current Stage 3 ECMAScript decorators proposal — a real, standardized JavaScript feature. This is the default when you write @decorator above a class and you have not set experimentalDecorators, and it is what modern TypeScript code should use going forward. This lesson uses that modern API exclusively.

A class decorator is just a function with a specific shape. When you write:

@myDecorator
class Widget { }

The compiler calls myDecorator(Widget, context) immediately after the class body has been fully evaluated, where:

  • The first argument is the class itself — the constructor function, not an instance.
  • The second argument is a context object describing the class: its kind (always the literal "class" for a class decorator), its name, and an addInitializer method for scheduling extra setup.

The decorator can then do one of two things: return nothing (undefined/void), leaving the original class untouched, or return a brand-new constructor, which then replaces the original binding everywhere — every subsequent new Widget(), every instanceof Widget check, and every import of Widget from that module will refer to the replacement.

An important nuance: decorators are not erased

Most TypeScript-only syntax — type annotations, interfaces, generic parameters, as assertions — disappears completely when compiled to JavaScript; it exists purely to help the type checker and has zero runtime footprint. Class decorators are different: they are real, standardized JavaScript behavior, not a type-system feature. The @myDecorator application and the decorator function’s logic remain in the compiled output and execute every time the module loads that class declaration. What gets erased is only the type annotations inside the decorator’s signature (target: T, context: ClassDecoratorContext) — the call itself is not optional and not stripped.

Syntax

@decoratorExpression
class ClassName {
  // class members
}
Part Meaning
decoratorExpression A decorator function, or a call to a decorator factory (a function that returns a decorator function) when you need to pass configuration.
target (1st param) The class constructor being decorated. Often typed as Function, or as a generic T extends new (...args: any[]) => object when you need to extend it.
context (2nd param) A ClassDecoratorContext object: { kind: "class", name: string | undefined, addInitializer(fn), metadata }.
Return value void to leave the class unchanged, or a new constructor to replace it.

Multiple decorators can be stacked above a single class, one per line. Their expressions are evaluated top to bottom, but they are applied bottom to top — see the third example below.

Examples

Example 1: Logging construction with a mixin-style decorator

This is the classic pattern for a decorator that wraps a class: declare it generically over T so you can safely write class extends target and keep every original member and constructor parameter.

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

function logged<T extends Constructor>(target: T, context: ClassDecoratorContext) {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      console.log(`Created a new ${context.name} instance`);
    }
  };
}

@logged
class Product {
  constructor(public name: string, public price: number) {}
}

const p = new Product("Keyboard", 49.99);
console.log(p.name, p.price);

Output:

Created a new Product instance
Keyboard 49.99

logged receives Product as target and returns a brand-new anonymous class that extends it, adding a constructor that logs before delegating to super(...args). Because the replacement class extends target, it keeps every property, method, and the original constructor’s parameter list — instanceof Product still works, since the returned class’s prototype chain includes the original.

Example 2: A parameterized decorator factory (sealing a class)

When a decorator needs configuration, write a plain function that returns the decorator — this is a decorator factory. This example follows the standard pattern of typing the target as Function, which is enough when you only need to call methods like Object.freeze and don’t need to extend it.

function sealed(message: string) {
  return function (target: Function, context: ClassDecoratorContext) {
    console.log(message);
    Object.freeze(target);
    Object.freeze(target.prototype);
  };
}

@sealed("Sealing the Account class")
class Account {
  balance = 0;
  constructor(public owner: string) {}
}

const acc = new Account("Ada");
console.log(acc.owner, acc.balance);

Output:

Sealing the Account class
Ada 0

sealed("...") is called first and returns the actual decorator function, which the compiler then applies to Account. Object.freeze on the constructor and its prototype prevents anyone from adding new static members or reassigning methods later — but it does not stop normal instantiation, because each new Account(...) call still creates a fresh, unfrozen instance object.

Example 3: Auto-registering classes with addInitializer

A very realistic use of class decorators is building a registry — think of a UI component library, a router, or a plugin system that needs to know about every decorated class.

type Constructor = new (...args: any[]) => object;
const registry = new Map<string, Constructor>();

function registerComponent<T extends Constructor>(target: T, context: ClassDecoratorContext) {
  const className = context.name ?? "Anonymous";
  context.addInitializer(function () {
    console.log(`${className} is ready`);
  });
  registry.set(className, target);
  return target;
}

@registerComponent
class Button {
  constructor(public label: string) {}
}

@registerComponent
class Checkbox {
  constructor(public checked: boolean) {}
}

console.log([...registry.keys()]);

const ButtonClass = registry.get("Button");
if (ButtonClass) {
  const instance = new ButtonClass("Submit") as Button;
  console.log(instance.label);
}

Output:

Button is ready
Checkbox is ready
[ 'Button', 'Checkbox' ]
Submit

context.addInitializer schedules a callback that runs right after the class is fully finalized — useful for side effects that must happen once the whole class definition (including any static members) is settled, rather than in the middle of the decorator’s own logic. The registry itself is just a plain Map, showing that class decorators are ordinary runtime code with full access to closures and outside state.

Under the Hood: Step by Step

  1. TypeScript first evaluates the entire class body — fields, methods, static members — producing the base constructor function, exactly as if no decorator were present.
  2. Each decorator attached to the class is then called with (currentClass, context). If several decorators are stacked, they are applied bottom-to-top: the one closest to the class keyword runs first, and its result (if any) is fed into the next one up.
  3. If a decorator returns a new constructor, that value becomes the class going forward — every later reference to the class name in that scope (including new ClassName(), exports, and instanceof) resolves to the replacement.
  4. Once all class decorators have been applied and a final constructor is settled, any callbacks registered with context.addInitializer run synchronously, in the order they were registered.
  5. Only then does execution continue to the next statement after the class declaration.

At compile time, the type annotations on the decorator’s parameters (: T, : ClassDecoratorContext) and any generics are stripped away, exactly like everywhere else in TypeScript. What survives into the emitted JavaScript is the decorator call itself and its logic, because — unlike types — decorators are a genuine runtime language feature, not a compile-time-only annotation.

Common Mistakes

Mistake 1: Forgetting to return the replacement class

If your decorator builds a wrapper class but never returns it, TypeScript does not report an error — a decorator is allowed to return void. The bug is silent and purely logical.

function addTimestamp<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
  class Wrapped extends target {
    createdAt = new Date().toISOString();
  }
  // BUG: forgot to `return Wrapped;`
}

@addTimestamp
class Order {
  constructor(public id: number) {}
}

const o = new Order(1);
console.log((o as any).createdAt);

Output:

undefined

Wrapped is built but discarded, so Order is completely unaffected — and because a missing return just makes the function’s inferred return type void, which is a perfectly legal decorator return type, tsc has no reason to complain. The fix is to actually return the wrapper:

function addTimestamp<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
  return class extends target {
    createdAt = new Date().toISOString();
  };
}

@addTimestamp
class Order {
  constructor(public id: number) {}
}

const o = new Order(1);
console.log(typeof (o as any).createdAt);

Output:

string

Mistake 2: Returning a class that doesn’t match the original shape

Unlike Mistake 1, this one is caught by the compiler. If your decorator returns a completely unrelated class instead of extending target, the return value no longer satisfies the generic type T that was inferred from the decorated class, and tsc rejects it.

function broken<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
  return class {
    extra = true;
  };
}

@broken
class Widget {
  constructor(public id: number) {}
}

tsc reports an error at the @broken line, essentially: the object returned ({ new(): { extra: boolean } }) is not assignable to T, because T was inferred as typeof Widget — a class with a different constructor parameter list and different members. The fix is to make the replacement genuinely compatible by extending target:

function fixed<T extends new (...args: any[]) => object>(target: T, context: ClassDecoratorContext) {
  return class extends target {
    extra = true;
  };
}

@fixed
class Widget {
  constructor(public id: number) {}
}

const w = new Widget(5);
console.log(w.id, (w as any).extra);

Output:

5 true

Mistake 3: Assuming stacked decorators apply top-to-bottom

When you stack several decorators, it’s tempting to assume the one written first runs first. In fact they are applied in the opposite order — bottom to top, closest to the class first.

function first(target: Function, context: ClassDecoratorContext) {
  console.log("first: applied");
}

function second(target: Function, context: ClassDecoratorContext) {
  console.log("second: applied");
}

@first
@second
class Widget {}

new Widget();

Output:

second: applied
first: applied

Even though @first is written above @second, second — the one closest to the class keyword — is called first, and its result feeds into first. This mirrors function composition, first(second(Widget)), and matches the order used by the legacy decorator implementation too, so it’s worth memorizing rather than re-deriving each time.

Best Practices

  • Prefer the modern, context-based decorator API (as used throughout this lesson) for new code — it needs no experimentalDecorators flag and is the actual ECMAScript standard. Reserve the legacy API for frameworks that still require it.
  • Decide explicitly whether your decorator mutates in place (return void) or replaces the class (return a new constructor), and make that clear from the decorator’s name — callers relying on instanceof or static members need to know.
  • When replacing a class, extend target rather than building an unrelated class, so the prototype chain, instanceof checks, and inherited members keep working.
  • Type decorator parameters generically (<T extends new (...args: any[]) => object>) instead of any, so consumers of the decorated class keep full type safety and autocompletion.
  • Use context.addInitializer for setup that must run only after the entire class (including static members) is finalized, rather than burying it in the middle of the decorator body.
  • Remember decorators run once, when the class is declared — not once per instance. Don’t reach for a class decorator when you actually need per-instance logic; that belongs in the constructor or a method decorator.
  • Name decorator factories as verbs or adjectives (@sealed, @registerComponent, @withVersion) so @decoratorName above a class reads naturally.

Practice Exercises

  • Exercise 1: Write a class decorator frozen that freezes both the decorated class and its prototype using Object.freeze, similar to the sealed example. Apply it to a small class and confirm (by reasoning about the code, or trying it yourself) that assigning a new static property to the class afterward would throw in strict mode.
  • Exercise 2: Write a decorator factory withVersion(v: string) that adds a static version property equal to v to any class it decorates, while preserving the original class’s constructor and instance behavior. Hint: return a class that extends target and declares static version = v;.
  • Exercise 3: Write a class decorator trackInstances that keeps a running count of how many instances of the decorated class have been created, exposed as a static count property. Hint: wrap the constructor in a subclass and increment a counter (declared via a static field on the replacement class) inside it. Create three instances and confirm count equals 3.

Summary

  • Class decorators are functions applied with @decoratorName above a class; they run once, when the class is declared, not per instance.
  • A decorator receives the class (target) and a context object (kind, name, addInitializer, metadata).
  • Returning void leaves the class unchanged; returning a new constructor replaces the class everywhere it’s referenced.
  • Decorators are real runtime JavaScript, not a type-only construct — only their type annotations are erased at compile time, not their behavior.
  • Stacked decorators evaluate top-to-bottom but apply bottom-to-top, like function composition.
  • Use decorator factories (a function returning a decorator) whenever you need to pass configuration into the decorator.
  • Prefer the modern, flag-free context-based decorator API for new TypeScript code; the legacy experimentalDecorators API remains only for compatibility with older frameworks.