If you’ve been writing TypeScript for the last five years, you’ve used decorators. You’ve typed @Injectable() in an Angular service, slapped @Column() on a TypeORM entity, or reached for a custom @Log to avoid repeating yourself. It worked. Mostly. You had "experimentalDecorators": true burning a hole in your tsconfig.json and you quietly ignored what "experimental" meant.
It meant the spec wasn’t done. And that the thing you were using was a proprietary TypeScript invention that diverged from the TC39 proposal years ago.
That’s over now. The TC39 Stage 3 Decorators proposal has landed in V8 (Chrome 130, Node 22+), TypeScript 5.0 shipped support for it without the legacy flag, and the spec is locked. This is the real thing.
The catch: it’s not the same as what you’ve been using. The signatures changed. The semantics changed. The mental model changed. And if you try to migrate your Angular/NestJS-style decorator code directly, you’ll get errors that make zero sense until you understand what the committee actually decided.
This article covers the spec as it stands in 2026 — what decorators are now, how each kind works, what the accessor keyword is about, and where the real sharp edges are.
The official proposal lives at https://github.com/tc39/proposal-decorators.
Why the Old Decorators Had to Die
TypeScript’s experimentalDecorators were based on an early 2015 proposal. That proposal used a design where decorators received (target, propertyKey, descriptor) — basically the arguments to Object.defineProperty. Frameworks built entire ecosystems on top of this. NestJS, Angular, TypeORM, MobX — all of them depend on this signature.
The problem is that this model has serious issues with class fields. ES2022 class fields use [[Define]] semantics, not [[Set]]. That means by the time a legacy decorator could intercept a field, the value was already written directly to the instance, bypassing any getter/setter you tried to install. The old spec had no clean answer for this.
The TC39 committee spent years reworking the proposal from scratch. The result is a cleaner, more principled design with a different call signature, a new context object, explicit support for accessor fields, and a built-in metadata system.
The New Signature
Every decorator — class, method, field, or accessor — is now a function that receives exactly two arguments:
function myDecorator(value, context) {
// value: the thing being decorated
// context: a rich object describing what you're decorating
}
context always has:
context.kind—"class","method","getter","setter","field", or"accessor"context.name— the name of the element (string or Symbol)context.static— boolean, true if this is a static membercontext.private— boolean, true if it’s a private field (#foo)context.addInitializer(fn)— registers a function to run during initializationcontext.metadata— a plain object shared across all decorators on the same class (more on this later)
What the decorator returns depends on what kind is. Return nothing (undefined), and the original value is used unchanged. Return a replacement value, and that’s what gets installed. Each kind has specific rules.
Class Decorators
A class decorator receives the class constructor as value and must return either nothing or a new constructor (which must extend the original, or at least be compatible with it):
function singleton(Class, context) {
// context.kind === "class"
let instance;
return function (...args) {
if (!instance) instance = new Class(...args);
return instance;
};
}
@singleton
class Config {
constructor() {
this.env = process.env.NODE_ENV ?? "development";
}
}
const a = new Config();
const b = new Config();
console.log(a === b); // true
You can also use context.addInitializer to run code after the class is fully defined but before it’s returned to the caller. This is useful for registration patterns:
const registry = new Map();
function register(Class, context) {
context.addInitializer(function () {
// `this` here is the class constructor
registry.set(this.name, this);
});
}
@register
class UserService {}
@register
class PostService {}
console.log(registry.get("UserService")); // [class UserService]
Method Decorators
A method decorator receives the original function as value and should return a replacement function or nothing:
function log(fn, context) {
// context.kind === "method"
return function (...args) {
console.log(`Calling ${String(context.name)} with`, args);
const result = fn.apply(this, args);
console.log(`${String(context.name)} returned`, result);
return result;
};
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
new Calculator().add(2, 3);
// Calling add with [2, 3]
// add returned 5
Notice fn.apply(this, args) — you need to preserve this. Arrow functions in the replacement would break this binding, so don’t use them as the outer wrapper.
A common gotcha: method decorators run on the prototype function, not the instance. If you want per-instance state, you need addInitializer:
function bound(fn, context) {
context.addInitializer(function () {
// `this` is the instance during initialization
this[context.name] = fn.bind(this);
});
}
class Button {
@bound
handleClick() {
console.log(this); // always the Button instance, even as a callback
}
}
This replaces the old TypeScript @autobind pattern, and it’s cleaner because it runs the bind at construction time without requiring a descriptor hack.
The accessor Keyword and Accessor Decorators
This is the genuinely new part of the spec — a new JavaScript keyword.
accessor creates an auto-accessor: a class field that automatically generates a private backing store plus a public getter and setter pair. Without any decorator, accessor foo = 42 is roughly equivalent to:
#foo = 42;
get foo() { return this.#foo; }
set foo(v) { this.#foo = v; }
The real power is that accessor decorators can intercept reads and writes in a way that was impossible with legacy decorators and plain class fields:
function clamp(min, max) {
return function (value, context) {
// value has: { get, set } — the original accessor pair
return {
get() {
return value.get.call(this);
},
set(v) {
value.set.call(this, Math.min(max, Math.max(min, v)));
},
init(v) {
return Math.min(max, Math.max(min, v));
}
};
};
}
class Volume {
@clamp(0, 100)
accessor level = 50;
}
const v = new Volume();
v.level = 150;
console.log(v.level); // 100
v.level = -10;
console.log(v.level); // 0
The returned object for an accessor decorator can have get, set, and init — init runs once per instance when the field is initialized, letting you transform the initial value.
This is the correct way to build reactive/observable properties, validation, computed fields, and anything that needed Object.defineProperty trickery in the old spec.
Field Decorators
Plain field decorators (no accessor) are more limited. The value passed is always undefined — you can’t intercept the actual field value at decoration time. What you can do is return an initializer function that runs per-instance:
function required(value, context) {
// value is undefined for fields
return function (initialValue) {
if (initialValue === null || initialValue === undefined) {
throw new Error(`Field ${String(context.name)} is required`);
}
return initialValue;
};
}
class User {
@required
name = null; // throws at construction time
}
The returned function receives the field’s initial value and its return value becomes the stored value. If you need reactive/interceptable fields, use accessor instead. Plain field decorators are mainly useful for initialization-time validation or transformation.
Metadata
Every decorator on a class shares a single context.metadata object. You write to it in decorators, and read it back from Class[Symbol.metadata] after the class is defined:
function route(path) {
return function (fn, context) {
context.metadata.routes ??= [];
context.metadata.routes.push({ path, method: context.name });
};
}
class UserController {
@route("/users")
list() {}
@route("/users/:id")
get() {}
}
console.log(UserController[Symbol.metadata].routes);
// [{ path: "/users", method: "list" }, { path: "/users/:id", method: "get" }]
This is the replacement for Reflect.metadata from the old reflect-metadata polyfill that Angular and TypeORM lean on heavily. The new metadata is simpler — it’s just a plain object, no registry, no Reflect API.
One important detail: metadata is inherited by subclasses via the prototype chain (the subclass’s [Symbol.metadata] inherits from the parent’s), but writes in a subclass decorator go to a fresh object that shadows the parent. Don’t mutate the parent’s metadata from a child class decorator.
Decorator Factories and Composition
If your decorator needs arguments, wrap it in a function. This is not a special feature of the spec — it’s just how JavaScript closures work:
function memoize(options = {}) {
return function (fn, context) {
const cache = new Map();
return function (...args) {
const key = options.keyFn ? options.keyFn(args) : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
};
}
class MathUtils {
@memoize({ keyFn: ([n]) => n })
fibonacci(n) {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
When you stack multiple decorators, they apply bottom-up — the decorator closest to the element applies first. This is the same order as function composition:
class Foo {
@A // applies second
@B // applies first
bar() {}
}
// Equivalent to: A(B(bar, context), context)
Get this backwards and you’ll spend an afternoon debugging why your auth check runs after your logging wrapper instead of before.
Gotchas
Migrating from experimentalDecorators: You cannot mix old and new decorators. If you’re on TypeScript, removing "experimentalDecorators": true from tsconfig.json switches to the new behavior. Frameworks like NestJS and Angular were still shipping legacy decorator support in 2025 and are actively migrating. Check your framework’s migration notes before removing that flag — you may be stuck waiting for upstream.
Private fields: context.private === true for decorators on #privateField. You cannot access the field by name from outside — the decorator can only use context.addInitializer to work with this. The name in context.name is a description string, not a usable key.
TypeScript type inference: When a method decorator returns a different function type, TypeScript may not automatically infer the new type. You may need to use overloads or explicit generic constraints on your decorator. The spec doesn’t mandate type inference behavior — that’s a TypeScript layer problem.
addInitializer order: Multiple addInitializer calls on the same element run in the order they were registered. But initializers from different decorators on the same class run in application order, which is bottom-up. Don’t assume initializers from stacked decorators run in declaration order.
Node.js and --experimental-vm-modules: As of Node 22, standard decorators work without flags. But if you’re running Jest with the old --experimental-vm-modules flag and Babel transforms, you might be getting the old decorator transform even on newer Node. Check what transform your build pipeline is actually using.
init return value on accessor decorators: If your accessor decorator’s init function returns undefined explicitly, the field is initialized to undefined, overriding the declared default. Return the initialValue unchanged if you don’t want to transform it.
What Still Isn’t There
Parameter decorators — decorating individual function parameters — are not in this proposal. They remain in a separate, lower-stage proposal. NestJS’s @Param(), @Body(), @Query() style parameter decorators are not spec compliant and still rely on the legacy TypeScript transform. This is arguably the biggest pain point for NestJS migration.
emitDecoratorMetadata — the TypeScript feature that emits design:type metadata via Reflect.metadata — is also gone from the new world. The replacement is the context.metadata system described above, but it requires library authors to explicitly write type information into metadata, rather than relying on TypeScript to emit it automatically. Libraries that depend on emitDecoratorMetadata for runtime type reflection (like TypeORM, class-validator, class-transformer) need updates.
Runtime Support in 2026
- V8 (Chrome 130+, Node 22+): Full support, no flags needed.
- SpiderMonkey (Firefox): Shipped in Firefox 130, flag removed in Firefox 133.
- JavaScriptCore (Safari): Shipped in Safari 18.2.
- TypeScript 5.x: Full support without
experimentalDecorators. The old behavior is still available behind the flag for legacy codebases but is considered deprecated. - Babel:
@babel/plugin-proposal-decoratorswithversion: "2023-11"implements the final spec. The"legacy"version still exists but you should be migrating off it.
For new projects, there’s no reason to use the old transform anymore. For existing projects with heavy framework dependency — especially Angular pre-19 or NestJS pre-11 — check the framework’s own migration guide before touching your tsconfig.
The Actual Bottom Line
The new decorator spec is better. The context object is more informative than (target, key, descriptor). The accessor keyword solves the class fields problem cleanly. The metadata API replaces a noisy third-party dependency. And the initializer model makes per-instance setup explicit rather than a side effect of descriptor manipulation.
The migration pain is real, but it’s mostly a framework problem, not a language problem. If you’re writing decorators yourself — for logging, caching, validation, route registration, observability — you can start using the new spec today, and the code will be cleaner for it.