Decorators and Metadata

This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

A decorator is a function that runs when a class is defined and can observe or replace the thing it decorates. TypeScript ships two incompatible implementations: the standard TC39 decorators (Stage 3, on by default) and the older legacy decorators behind experimentalDecorators. This page covers both and how to tell which one a project uses, following the handbook’s Decorators page.

Standard decorators (TC39 Stage 3)

Since TypeScript 5.0 the Stage 3 proposal is the default — no compiler flag, and the emitted code matches what JavaScript engines are shipping. A decorator is a function called with two arguments: the value being decorated and a context object. It either returns nothing or returns a replacement value.

Decorators are allowed on classes, methods, get/set accessors, fields, and accessor fields. They are not allowed on plain constructor parameters (that is a legacy-only feature).

Class decorators

A class decorator receives the class constructor and may return a replacement constructor.

function sealed(value: Function, context: ClassDecoratorContext) {
  if (context.kind !== "class") throw new Error("not a class");
  Object.seal(value);
  Object.seal(value.prototype);
}

@sealed
class Point {
  constructor(public x = 0, public y = 0) {}
}

Method, getter and setter decorators

A method decorator receives the method function and may return a wrapper with the same signature.

function logged<This, Args extends unknown[], Return>(
  target: (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 = target.call(this, ...args);
    console.log(`<- ${name}`);
    return result;
  };
}

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

new Calculator().add(2, 3); // logs "-> add(2, 3)" then "<- add", returns 5

Getter and setter decorators work the same way, with ClassGetterDecoratorContext / ClassSetterDecoratorContext and a returned function of the matching shape.

Field and accessor decorators

A field decorator’s value is always undefined; it may return an initializer-mapper (initialValue) ⇒ newValue that runs when each instance is constructed. The accessor keyword turns a field into a private slot plus a get/set pair, which an accessor decorator can wrap via \{ get, set, init }.

function capitalize(_: undefined, _ctx: ClassFieldDecoratorContext<unknown, string>) {
  return (initial: string) => initial.charAt(0).toUpperCase() + initial.slice(1);
}

function clamped(min: number, max: number) {
  return function (
    target: ClassAccessorDecoratorTarget<unknown, number>,
    _ctx: ClassAccessorDecoratorContext<unknown, number>,
  ): ClassAccessorDecoratorResult<unknown, number> {
    const fit = (n: number) => Math.max(min, Math.min(max, n));
    return {
      set(value: number) { target.set.call(this, fit(value)); },
      init(initial: number) { return fit(initial); },
    };
  };
}

class Volume {
  @capitalize name = "loud";
  @clamped(0, 100) accessor level = 50;
}

const v = new Volume();
v.name;             // "Loud"
v.level = 999;
v.level;            // 100

The context object

Every decorator’s second argument describes the target. Its exact type varies by kind, but it always carries:

  • kind — one of "class", "method", "getter", "setter", "field", "accessor".

  • name — the member name as a string or symbol (the class name, for a class decorator).

  • static — true for a static member (absent on class decorators).

  • private — true for a #private member.

  • addInitializer(fn) — registers fn to run during instance construction (or during class setup for static and class decorators).

  • access — an object \{ get, set, has } that reads and writes the member on an instance.

addInitializer is the standard replacement for the legacy "decorate the prototype" trick — for example, auto-binding a method to its instance:

function bound(_: Function, context: ClassMethodDecoratorContext) {
  context.addInitializer(function (this: any) {
    this[context.name] = this[context.name].bind(this);
  });
}

class Greeter {
  who = "world";
  @bound greet() { return `hi ${this.who}`; }
}

const { greet } = new Greeter();
greet();   // "hi world" -- still bound

Decorator factories

A factory is a function you call to produce a decorator, so the decorator can take configuration. @clamped(0, 100) above is a factory; @logged is a bare decorator. The expression after @ is evaluated — if it is a call, its result must be the actual decorator function.

function role(name: string) {
  return function (value: Function, context: ClassDecoratorContext) {
    context.addInitializer(() => console.log(`${String(context.name)} requires ${name}`));
  };
}

@role("admin")
class AdminPanel {}

Evaluation order vs. application order

Decorator expressions are evaluated top-to-bottom (so factory calls fire in reading order). The resulting decorators are then applied bottom-to-top for each decorated element, and elements are processed in source order.

function trace(label: string) {
  console.log(`eval ${label}`);
  return function (_v: unknown, _c: ClassMethodDecoratorContext) {
    console.log(`apply ${label}`);
  };
}

class Demo {
  @trace("A")
  @trace("B")
  one() {}

  @trace("C")
  two() {}
}
// eval A
// eval B
// eval C
// apply B   <- innermost decorator applied first
// apply A
// apply C

The legacy path: experimentalDecorators

Angular, NestJS and TypeORM predate the Stage 3 proposal and still rely on the original TypeScript decorators. That path is opt-in through two compiler flags plus a runtime polyfill.

{
  "compilerOptions": {
    "target": "ES2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Install and import the polyfill once, at the program entry point:

npm install reflect-metadata
import "reflect-metadata"; // side-effecting: installs the global Reflect.*Metadata API

// legacy class decorator: a single argument, the constructor
function Entity(constructor: Function) {
  Reflect.defineMetadata("entity", true, constructor);
}

// legacy method decorator: (target, propertyKey, descriptor)
function enumerable(value: boolean) {
  return function (target: object, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}

// legacy property decorator: (target, propertyKey) -- no descriptor
function Column(target: object, propertyKey: string) {
  const type = Reflect.getMetadata("design:type", target, propertyKey);
  console.log(`${propertyKey} is ${type?.name}`); // "id is Number" -- from emitDecoratorMetadata
}

@Entity
class User {
  @Column id!: number;

  @enumerable(false)
  save() {}
}

How the legacy signatures differ from the standard ones:

  • The first argument is target — the class prototype for instance members, the constructor for static members and class decorators — not the decorated value.

  • Members are identified by a separate propertyKey string/symbol argument; there is no context object, no kind, no addInitializer, no access.

  • Method and accessor decorators receive a mutable PropertyDescriptor as a third argument and change behavior by mutating it. Property decorators get no descriptor at all.

  • Parameter decorators exist here ((target, propertyKey, parameterIndex)) and are what NestJS uses for constructor injection.

  • With experimentalDecorators plus emitDecoratorMetadata, the compiler emits design:type, design:paramtypes and design:returntype entries that reflect-metadata exposes through Reflect.getMetadata. This reflection is what powers NestJS dependency injection and TypeORM column typing; the standard decorators emit no such metadata.

Which one do I have?

Check tsconfig.json:

flowchart TD A["tsconfig.json compilerOptions"] --> B{"experimentalDecorators: true?"} B -->|"yes"| C["Legacy decorators
signature (target, propertyKey, descriptor)
reflect-metadata + emitDecoratorMetadata available
Angular, NestJS, TypeORM"] B -->|"no / absent"| D["Standard TC39 decorators
signature (value, context)
TypeScript 5.0+, no flag"]
  • experimentalDecorators: true → the legacy path. Signatures are (target, propertyKey, descriptor), parameter decorators are allowed, and reflect-metadata is in play.

  • experimentalDecorators absent or falsestandard TC39 decorators. Signatures are (value, context).

  • The two modes cannot be mixed: a single experimentalDecorators setting switches the entire compilation, and decorator code written for one mode will not run under the other.

  • emitDecoratorMetadata only does anything under experimentalDecorators. There is no metadata emit for standard decorators — the TC39 Symbol.metadata proposal is the eventual replacement and is still settling.

See also