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 astringorsymbol(the class name, for a class decorator). -
static—truefor astaticmember (absent on class decorators). -
private—truefor a#privatemember. -
addInitializer(fn)— registersfnto 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
propertyKeystring/symbol argument; there is nocontextobject, nokind, noaddInitializer, noaccess. -
Method and accessor decorators receive a mutable
PropertyDescriptoras 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
experimentalDecoratorsplusemitDecoratorMetadata, the compiler emitsdesign:type,design:paramtypesanddesign:returntypeentries thatreflect-metadataexposes throughReflect.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:
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, andreflect-metadatais in play. -
experimentalDecoratorsabsent orfalse→ standard TC39 decorators. Signatures are(value, context). -
The two modes cannot be mixed: a single
experimentalDecoratorssetting switches the entire compilation, and decorator code written for one mode will not run under the other. -
emitDecoratorMetadataonly does anything underexperimentalDecorators. There is no metadata emit for standard decorators — the TC39Symbol.metadataproposal is the eventual replacement and is still settling.
See also
-
Classes — the class members that decorators attach to.
-
TypeScript Essentials for Angular — why Angular projects still enable
experimentalDecorators. -
tsconfig.json and Compiler Options —
experimentalDecorators,emitDecoratorMetadataand related flags. -
JavaScript Interop and Migration — how decorated code is emitted and consumed from plain JavaScript.