TypeScript Decorators: Standard (TS 5+) vs. Legacy Explained
Standard decorators work out of the box since TypeScript 5.0. Learn how they work, how they differ from legacy experimentalDecorators, and which one to use.
Update (October 2026): I rewrote this post. It now covers standard decorators (TypeScript 5.0+, no flag) with
examples, decorator metadata (TS 5.2), runtime and tooling support incl. TypeScript 7, and a comparison with the legacy
experimentalDecorators the original 2021 post was about.
What are TypeScript decorators?
A decorator is a function you attach to a class or class member with @. It gets called when the class is defined
and can replace, wrap, or initialize the thing it decorates. Typical uses: logging, validation, binding this,
registering classes, or attaching metadata.
class Calculator { @logged add(a: number, b: number) { return a + b; }}TypeScript has two incompatible decorator implementations:
- Standard decorators (TypeScript 5.0+): follow the TC39 decorators proposal. They work without any compiler flag.
- Legacy decorators (
"experimentalDecorators": true): TypeScript’s implementation of an old draft from 2015. This is what Angular, NestJS, TypeORM and InversifyJS are built on.
The experimentalDecorators flag decides which one you get for the whole project. If it is set, every decorator is a
legacy decorator.
Standard decorators (TypeScript 5.0+)
A standard decorator has this shape:
type Decorator = ( value: Input, // the decorated method, getter, setter, class, ... (undefined for fields) context: { kind: 'class' | 'method' | 'getter' | 'setter' | 'field' | 'accessor'; name: string | symbol | undefined; // undefined only for anonymous classes static: boolean; // not on class decorators private: boolean; // not on class decorators access: { // not on class decorators; methods/getters have no set, setters no get has(object: This): boolean; get(object: This): Value; set(object: This, value: Value): void; }; addInitializer(initializer: () => void): void; metadata: DecoratorMetadataObject; // TS 5.2+, typed `| undefined` if your lib lacks Symbol.metadata },) => Output | void;The decorator gets the original value plus a context object and can return a replacement of the same kind: a
method for a method, a class for a class. It can’t turn a method into a field. TypeScript ships a typed context for
every kind: ClassDecoratorContext, ClassMethodDecoratorContext, ClassGetterDecoratorContext,
ClassSetterDecoratorContext, ClassFieldDecoratorContext and ClassAccessorDecoratorContext.
addInitializer() registers a function that runs once the decorated thing is set up: in the constructor for instance
members, at class definition for static members and classes.
Method decorator: logging
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} returned ${result}`); return result; };}
class Calculator { @logged add(a: number, b: number) { return a + b; }}
new Calculator().add(1, 2);// → add(1, 2)// ← add returned 3The generics look heavy, but they keep this, the arguments and the return type intact. If you don’t care, target: any, context: ClassMethodDecoratorContext works as well.
Class decorator
A class decorator receives the class itself. Here it uses addInitializer(), which runs after the class is fully
defined (incl. static fields):
function sealed<Class extends abstract new (...args: any) => any>(target: Class, context: ClassDecoratorContext<Class>) { context.addInitializer(function () { Object.seal(this); Object.seal(this.prototype); });}
@sealedclass Order { static currency = 'EUR';}
// strict mode (ES modules, "use strict"): TypeError: Cannot add property discount, object is not extensible// sloppy mode (plain scripts): silently ignored(Order as any).discount = 10;A class decorator may also return a new class (e.g. a subclass of target) to replace the original one. For a deeper
dive into class decorators see the follow-up post below.
Field decorator: returns an initializer
Fields don’t exist yet when the decorator runs, so value is undefined. Instead, a field decorator can return a
function that receives the initial value and returns the value the field actually gets:
function trimmed<This>(_value: undefined, context: ClassFieldDecoratorContext<This, string>) { return function (this: This, initialValue: string) { return initialValue.trim(); };}
class User { @trimmed name = ' Jannik ';}
const user = new User();user.name; // 'Jannik'user.name = ' Jannik ';user.name; // ' Jannik ' – later assignments are not interceptedAccessor decorator: intercept every read and write
To intercept later assignments, use the accessor keyword (an “auto-accessor”, available since TypeScript 4.9). It
turns the field into a getter/setter pair backed by private storage. An accessor decorator can return new get, set and init
functions. This one also is a decorator factory (see next section):
function min(limit: number) { return function <This>( target: ClassAccessorDecoratorTarget<This, number>, context: ClassAccessorDecoratorContext<This, number>, ): ClassAccessorDecoratorResult<This, number> { const check = (value: number) => { if (value < limit) throw new RangeError(`${String(context.name)} must be >= ${limit}, got ${value}`); return value; };
return { init: check, set(value) { target.set.call(this, check(value)); }, }; };}
class Cart { @min(1) accessor quantity = 1;}
const cart = new Cart();cart.quantity = 3; // finecart.quantity = 0; // RangeError: quantity must be >= 1, got 0Decorator factories
To pass options to a decorator, write a function that returns the decorator. That’s a decorator factory:
function log(prefix: string) { return function <This, Args extends unknown[], Return>( target: (this: This, ...args: Args) => Return, context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>, ) { return function (this: This, ...args: Args): Return { console.log(`${prefix} ${String(context.name)}`); return target.call(this, ...args); }; };}
class Api { @log('[api]') fetchOrders() { return []; }}
new Api().fetchOrders(); // [api] fetchOrdersDecorator metadata (TypeScript 5.2+)
TypeScript 5.2
added context.metadata: one plain object shared by all decorators of a class and available afterwards as
MyClass[Symbol.metadata]. That’s the standard replacement for reflect-metadata style annotations:
// runtimes don't ship Symbol.metadata yet, so polyfill it(Symbol as { metadata?: symbol }).metadata ??= Symbol('Symbol.metadata');
function serializable(_value: undefined, context: ClassFieldDecoratorContext) { const fields = (context.metadata.serializable ??= []) as (string | symbol)[]; fields.push(context.name);}
class Invoice { @serializable id = 'inv-1'; @serializable total = 42; internalNote = 'do not export';}
Invoice[Symbol.metadata]?.serializable; // ['id', 'total']You need "esnext" or "esnext.decorators" in your lib setting for the Symbol.metadata types.
Order of evaluation
function trace(label: string) { console.log(`evaluate ${label}`); return function (_target: unknown, context: DecoratorContext) { console.log(`apply ${label} to ${context.kind} ${String(context.name)}`); };}
@trace('class')class Demo { @trace('first') @trace('second') method() {}
@trace('field') field = 1;}
// evaluate class// evaluate first// evaluate second// evaluate field// apply second to method method// apply first to method method// apply field to field field// apply class to class Demo- Decorator expressions (the factory calls) are evaluated top to bottom.
- Stacked decorators are applied bottom-up: the one closest to the member wraps first.
- The class decorator is applied after all member decorators.
What’s not in the standard
Parameter decorators (constructor(@Inject('DB') db: Db)) are not part of the TC39 proposal and TypeScript
only supports them with experimentalDecorators. Same for emitDecoratorMetadata, which doesn’t work with standard
decorators.
Legacy decorators (experimentalDecorators)
Before TypeScript 5.0, this was the only option. You enable it in tsconfig.json:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true // optional, see below }}Legacy decorators get target (the prototype for instance members, the constructor for static members),
propertyKey and, for methods and accessors, a property descriptor. The signatures in lib.decorators.legacy.d.ts:
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;// also used for accessors (getter/setter)declare type MethodDecorator = <T>( target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>,) => TypedPropertyDescriptor<T> | void;declare type ParameterDecorator = ( target: Object, propertyKey: string | symbol | undefined, parameterIndex: number,) => void;The logging decorator from above, legacy style:
function logged(target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (this: unknown, ...args: unknown[]) { console.log(`→ ${String(propertyKey)}`); return original.apply(this, args); };}Decorator factories and composition work the same way as with standard decorators: factories are evaluated top to bottom, decorators are applied bottom-up. Within a class, TypeScript applies instance member decorators first, then static member decorators, then constructor parameter decorators and finally the class decorator.
Why Angular, NestJS, TypeORM and InversifyJS still use it
The dependency injection of NestJS, TypeORM and InversifyJS relies on two things the standard doesn’t offer:
- Parameter decorators like
constructor(@Inject(TOKEN) repo: Repo). emitDecoratorMetadata: TypeScript emits the design-time types (design:type,design:paramtypes,design:returntype) of decorated members, which the framework reads at runtime viareflect-metadata. That’s how NestJS knows which service to inject into a constructor without you naming it.
Angular is different: its compiler reads constructor parameter types from the source at build time, so it doesn’t
need emitDecoratorMetadata. And with the inject() function, which Angular now
prefers over constructor injection, you don’t need parameter decorators either.
Still, both nest new and ng new generate a tsconfig.json with "experimentalDecorators": true (only NestJS also
sets emitDecoratorMetadata). If you use one of these frameworks, keep the flag. Don’t mix both styles in one project.
Standard vs. legacy decorators
| Standard decorators | Legacy decorators | |
|---|---|---|
| Enable | Nothing (TypeScript 5.0+) | "experimentalDecorators": true |
| Signature | (value, context) | (target, propertyKey, descriptor?) |
| Based on | TC39 decorators proposal | 2015 draft, TypeScript-specific |
| Class, method, getter/setter | ✅ | ✅ |
| Fields | ✅ can return an initializer | ⚠️ only get prototype and name, not the value |
accessor keyword | ✅ incl. init hook | ⚠️ via get/set descriptor, no init hook |
#private members | ✅ | ❌ |
| Parameter decorators | ❌ | ✅ |
| Metadata | context.metadata / Symbol.metadata (TS 5.2+) | emitDecoratorMetadata + reflect-metadata |
| Used by | New code without framework constraints | Angular, NestJS, TypeORM, InversifyJS |
For new code without such a framework: use standard decorators.
Runtime and tooling support (October 2026)
No JavaScript engine ships decorators natively. In May 2026, TC39 even moved the proposal back from Stage 3 to Stage 2.7 (decorator metadata as well), because after four years at Stage 3 no engine had shipped them. Stage 2.7 still means the committee expects decorators to become part of JavaScript, but details may change.
So you always need a compiler that transforms decorators into plain JavaScript:
- TypeScript (
tsc) transforms both standard and legacy decorators. Standard decorators are transformed for everytargetbelowESNext. WithESNext, they stay in the output as-is, which no runtime can execute today. - TypeScript 7 (the native Go port,
tscfromtypescript@7) emits both styles, incl.emitDecoratorMetadata. Support landed in typescript-go#2343 (legacy) and typescript-go#2926 (standard). - esbuild (and therefore tsx) supports standard decorators since
0.21.0 and legacy ones if
experimentalDecoratorsis set intsconfig.json. It does not supportemitDecoratorMetadata, which is why NestJS projects usually stick totscor SWC. - Babel:
@babel/plugin-proposal-decoratorswithversion: "2023-11"for standard decorators orversion: "legacy".
Next: class decorators and dependency injection
In the follow-up post
TypeScript class decorators incl. dependency injection example
I build a small dependency injection container with class decorators. Note that it uses legacy decorators
(experimentalDecorators), like NestJS and Angular do.
Resources
🔗 TypeScript 5.0 release notes: Decorators
🔗 TypeScript 5.2 release notes: Decorator Metadata