JavaScript Decorators (TC39 Stage 3): The Breaking Change from Legacy
TC39 Stage 3 decorators are incompatible with TypeScript's legacy implementation — here's what changed
JavaScript decorators have been in limbo for over eight years. TypeScript shipped experimental decorators in 2015 based on an early spec draft, Angular and NestJS built empires on them, and then TC39 changed everything. The Stage 3 proposal that's landing in browsers is fundamentally incompatible with what you've been using — and migrating isn't straightforward.
What Decorators Actually Are
Decorators are functions that receive a value being defined and can replace or augment it. They run at class definition time, not instantiation:
@logged
class MyClass {
@reactive accessor count = 0;
@bound
handleClick() {}
}
// Decorators are just functions
function logged(value, context) {
console.log(`Defining: ${context.name}`);
return value;
}The key insight: decorators can only replace a value with something of the same kind. A method decorator returns a method, a class decorator returns a class. No more magical transformations.
The Legacy vs. Stage 3 Incompatibility
TypeScript's experimental decorators (enabled via experimentalDecorators) use a completely different signature:
// ❌ Legacy TypeScript decorator signature
function legacyDecorator(
target: any, // The class prototype
propertyKey: string, // Method name
descriptor: PropertyDescriptor // Property descriptor
) {
// Mutates descriptor
}
// ✅ Stage 3 decorator signature
function modernDecorator(
value: Function, // The actual method
context: {
kind: 'method' | 'getter' | 'setter' | 'field' | 'accessor' | 'class';
name: string | symbol;
static: boolean;
private: boolean;
access?: { get(): unknown; set?(v: unknown): void };
addInitializer(fn: () => void): void;
}
) {
return value; // Return replacement or undefined
}The fundamental difference: legacy decorators mutate a property descriptor, Stage 3 decorators receive the value directly and return a replacement.
The context Object
Every decorator receives a context object with metadata about what's being decorated:
- kind — What type of element: 'class', 'method', 'getter', 'setter', 'field', 'accessor'
- name — The element's name (string or symbol)
- static — Whether it's a static member
- private — Whether it's private
- access — Object with get/set for accessing the value on instances
- addInitializer — Register code to run during initialization
function debug(value, context) {
console.log(`Decorating ${context.kind}: ${String(context.name)}`);
console.log(`Static: ${context.static}, Private: ${context.private}`);
return value;
}
class Example {
@debug x = 1; // kind: 'field', name: 'x'
@debug static y = 2; // kind: 'field', name: 'y', static: true
@debug #z = 3; // kind: 'field', name: '#z', private: true
}The accessor Keyword
Stage 3 introduces a new class element — auto accessors — that decorators can target:
class Counter {
accessor count = 0; // Creates getter/setter backed by private storage
}
// Equivalent to:
class Counter {
#count = 0;
get count() { return this.#count; }
set count(v) { this.#count = v; }
}This exists because field decorators can't intercept get/set — they only see initialization. Auto accessors provide the hook point:
function reactive(value, context) {
if (context.kind !== 'accessor') {
throw new Error('@reactive only works on accessors');
}
return {
get() {
track(this, context.name);
return value.get.call(this);
},
set(v) {
value.set.call(this, v);
trigger(this, context.name);
},
init(initialValue) {
return initialValue;
}
};
}
class State {
@reactive accessor count = 0; // Now tracked!
}Decorator Evaluation Order
Decorators evaluate in a specific order that matters for composition:
- Decorator expressions evaluate top-to-bottom, left-to-right (interleaved with computed property names)
- Decorators call bottom-to-top (inner first, like function composition)
- Results apply all at once after all decorators run
function a(name) {
console.log(`eval @${name}`);
return (v, ctx) => {
console.log(`call @${name}`);
return v;
};
}
class C {
@a('outer') @a('inner')
method() {}
}
// Logs:
// eval @outer
// eval @inner
// call @inner (bottom-to-top calling!)
// call @outeraddInitializer: The Hidden Power
addInitializer lets you register code that runs during construction:
function bound(value, context) {
if (context.kind !== 'method') {
throw new Error('@bound only works on methods');
}
context.addInitializer(function() {
// 'this' is the instance being created
this[context.name] = this[context.name].bind(this);
});
return value;
}
class Button {
@bound
handleClick() {
console.log(this); // Always the Button instance
}
}
const btn = new Button();
const handler = btn.handleClick;
handler(); // 'this' is correctly boundMigration Strategy
If you're using legacy decorators, you can't just swap. Libraries need to support both:
// Detect signature by argument count/shape
function logged(...args) {
// Legacy: (target, key, descriptor)
if (args.length === 3 && typeof args[1] === 'string') {
const [target, key, descriptor] = args;
const original = descriptor.value;
descriptor.value = function(...a) {
console.log(`Calling ${key}`);
return original.apply(this, a);
};
return descriptor;
}
// Stage 3: (value, context)
const [value, context] = args;
if (context.kind === 'method') {
return function(...a) {
console.log(`Calling ${context.name}`);
return value.apply(this, a);
};
}
return value;
}The TC39 proposal explicitly recommends this dual-support pattern during transition.
What's NOT Supported
Stage 3 decorators deliberately omit some legacy capabilities:
- Parameter decorators — No
@injecton constructor params - Decorator metadata emission —
emitDecoratorMetadatahas no equivalent - Arbitrary value transformation — Can't turn a method into a property
Angular, NestJS, and TypeORM heavily use parameter decorators and metadata. Their migration paths involve significant rewrites or shims.
Current Support
Stage 3 decorators are available in:
- TypeScript 5.0+ — Without
experimentalDecoratorsflag - Babel — Via
@babel/plugin-proposal-decoratorswithversion: "2023-11" - Browsers — Not yet (waiting for Stage 4)
- Node.js — Not yet (type stripping won't help — needs actual transformation)
For production, transpilation is still required. Native support is expected once the proposal reaches Stage 4 and implementations ship.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement