Skip to content
By 10 min readUpdated

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 3

The 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);
});
}
@sealed
class 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 intercepted

Accessor 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; // fine
cart.quantity = 0; // RangeError: quantity must be >= 1, got 0

Decorator 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] fetchOrders

Decorator 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
  1. Decorator expressions (the factory calls) are evaluated top to bottom.
  2. Stacked decorators are applied bottom-up: the one closest to the member wraps first.
  3. 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 via reflect-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 decoratorsLegacy decorators
EnableNothing (TypeScript 5.0+)"experimentalDecorators": true
Signature(value, context)(target, propertyKey, descriptor?)
Based onTC39 decorators proposal2015 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❌✅
Metadatacontext.metadata / Symbol.metadata (TS 5.2+)emitDecoratorMetadata + reflect-metadata
Used byNew code without framework constraintsAngular, 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 every target below ESNext. With ESNext, they stay in the output as-is, which no runtime can execute today.
  • TypeScript 7 (the native Go port, tsc from typescript@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 experimentalDecorators is set in tsconfig.json. It does not support emitDecoratorMetadata, which is why NestJS projects usually stick to tsc or SWC.
  • Babel: @babel/plugin-proposal-decorators with version: "2023-11" for standard decorators or version: "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

🔗 TypeScript Handbook: Decorators (legacy)

🔗 TC39 decorators proposal

🔗 TC39 decorator metadata proposal