Skip to content
TypeScript ts decorators 3 min read

Class Decorators

A class decorator runs once, when the class is defined, and receives the class itself. It can observe the class (register it somewhere, log it), wrap it in a subclass to add behavior, or replace it entirely. Class decorators are the most common entry point into the feature because frameworks use them to mark a class as a controller, module, or entity. This page shows the Stage 3 signature, how to return a replacement class, and how the legacy form differs.

Stage 3 class decorators

A standard class decorator is a function (value, context) where value is the class constructor and context is a ClassDecoratorContext. Returning void keeps the class as-is; returning a new class replaces it.

function sealed(value: Function, context: ClassDecoratorContext) {
  if (context.kind !== "class") return;
  Object.seal(value);
  Object.seal(value.prototype);
}

@sealed
class Point {
  constructor(public x: number, public y: number) {}
}

Here sealed freezes the constructor and prototype so no new members can be added at runtime. The context.kind check is "class", and context.name gives the class name (or undefined for anonymous class expressions).

The class decorator context

The ClassDecoratorContext object exposes a small but useful API. The most important member is addInitializer, which schedules a callback to run when the class is set up — handy for self-registration.

MemberTypePurpose
kind"class"Discriminates the decorator target
namestring | undefinedThe class name
addInitializer(fn: () => void) => voidRun logic at class-definition time
metadataobjectShared bag for decorator metadata
const registry = new Map<string, Function>();

function register(value: Function, context: ClassDecoratorContext) {
  context.addInitializer(function () {
    registry.set(String(context.name), this);
  });
}

@register
class UserService {}

Inside the initializer, this is the class itself, so registry ends up mapping "UserService" to its constructor.

Replacing the class

To augment instances, return a subclass from the decorator. The returned constructor becomes the value bound to the class name, so callers transparently get the enhanced version.

function withTimestamp<T extends new (...args: any[]) => object>(
  value: T,
  _context: ClassDecoratorContext,
) {
  return class extends value {
    createdAt = new Date();
  };
}

@withTimestamp
class Order {
  constructor(public id: string) {}
}

const order = new Order("A-1");
// order.createdAt is a Date, fully typed

When returning a replacement, constrain the generic to new (...args: any[]) => object so extends value is legal. Without the constructor constraint TypeScript rejects subclassing the parameter.

Decorator factories

A decorator factory is a function that returns a decorator, letting you pass configuration. This pattern powers nearly every framework decorator, such as @Controller("users").

function entity(tableName: string) {
  return function (value: Function, context: ClassDecoratorContext) {
    context.addInitializer(function () {
      (this as any).tableName = tableName;
    });
  };
}

@entity("users")
class User {}

The outer entity captures tableName; the inner function is the actual decorator. Calling @entity("users") invokes the factory and applies its result.

Legacy class decorators

Under experimentalDecorators, a class decorator receives only the constructor — there is no context. Returning a new constructor replaces the class, just as in Stage 3, but the augmentation pattern looks different.

// tsconfig: "experimentalDecorators": true
function Injectable(constructor: Function) {
  console.log(`Registering ${constructor.name}`);
}

@Injectable
class AuthService {}
function WithId<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    id = crypto.randomUUID();
  };
}

@WithId
class Session {}

The legacy form is what NestJS and Angular use today. Functionally it covers the same ground as Stage 3 for classes, but its signature and the lack of a context object are the giveaways for which system is active.

Best Practices

  • Check context.kind === "class" in reusable decorators to fail fast on misuse.
  • Use addInitializer for self-registration instead of mutating module-level state eagerly.
  • Constrain generics to new (...args: any[]) => object when returning a subclass.
  • Prefer decorator factories when a decorator needs configuration arguments.
  • Keep replacement subclasses minimal so instance types stay predictable for callers.
  • Do not mix legacy and Stage 3 class decorators in one project; pick one system.
  • Reserve class decorators for cross-cutting concerns, not core business logic.
Last updated June 29, 2026
Was this helpful?