
A Few Hard Truths About the New Decorator Standard
Moving from experimental to native decorators isn't just a syntax update; it's a fundamental change in how your code observes itself.
Why did it take ten years to standardize something that felt "finished" in 2014?
If you’ve been using TypeScript or Babel for any length of time, decorators probably feel like a solved problem. You slap an @autobind or @observable on a class member, and it just works. But for nearly a decade, we’ve been living in a state of architectural sin. We’ve been building massive ecosystems—Angular, NestJS, MobX—on top of an "experimental" implementation that was never actually going to be the final standard.
The Stage 3 Decorator proposal is finally here, and it’s landing in browsers and runtimes right now. But here is the hard truth: it is not a drop-in replacement. If you think you can just flip a compiler flag and go about your day, you’re in for a rough afternoon.
The new standard represents a fundamental shift in how JavaScript observes itself. It’s more restrictive, more intentional, and significantly more powerful—but it requires you to unlearn almost everything you know about the "legacy" version.
The "Legacy" Lie
To understand where we are going, we have to admit where we were. The legacy decorators (the ones you use with experimentalDecorators: true) were essentially a wild-west implementation of an early proposal. They worked by passing the property descriptor to a function, letting you mutate it, and returning a new one.
It was flexible, sure. But it was also a nightmare for engines like V8 to optimize because it allowed for arbitrary mutation of class structures at runtime.
The new standard (let's call them Standard Decorators) treats decorators as structured transformations. You aren't just messing with a descriptor anymore; you are participating in a well-defined lifecycle.
Truth #1: The Signature has Changed Completely
In the legacy world, a method decorator looked something like this:
// Legacy (Experimental)
function Log(target, key, descriptor) {
const original = descriptor.value;
descriptor.value = function(...args) {
console.log(`Calling ${key}`);
return original.apply(this, args);
};
return descriptor;
}In the new standard, the signature is strictly defined. You receive two arguments: the value being decorated and a context object.
// Standard (Stage 3)
function Log(value, context) {
const { kind, name } = context;
if (kind === "method") {
return function (...args) {
console.log(`Starting ${String(name)}`);
const result = value.apply(this, args);
return result;
};
}
}Notice the difference? We aren't touching a "descriptor" object. We are returning a new function that replaces the old one. The context object is the hero here; it provides metadata about what we are decorating without forcing us to sniff the target prototype.
Truth #2: The Context Object is Your New Best Friend
The context object is the most significant addition to the spec. It tells you exactly what you’re looking at and gives you tools to interact with it.
It contains:
- kind: Is this a class, method, getter, setter, field, or accessor?
- name: The name of the member (or the class).
- access: An object with get and set methods to reach the value.
- static: A boolean indicating if it’s a static member.
- private: A boolean indicating if it’s a #private member.
- addInitializer: A hook to run logic when the class is instantiated.
This addInitializer hook is a game-changer. In the old days, if you wanted to do something when an instance was created (like binding a method to this), you had to get messy with the constructor or the prototype.
Now, it’s a first-class citizen:
function Bound(value, context) {
const { name, addInitializer } = context;
if (context.kind === "method") {
addInitializer(function () {
this[name] = this[name].bind(this);
});
}
}
class Printer {
@Bound
print() {
console.log(this);
}
}Truth #3: Parameter Decorators are Dead (For Now)
This is the one that’s going to hurt the NestJS and InversifyJS crowds. The current Stage 3 spec does not support parameter decorators.
In legacy TypeScript, you could do this:
// This is NOT supported in the new standard
class UserService {
constructor(@Inject(TYPES.Database) db) {
this.db = db;
}
}Why? Because the committee decided to focus on a "minimal viable" set of decorators to get the proposal across the finish line. Parameter decorators introduce a host of complexities regarding how and when arguments are evaluated.
If your entire architecture relies on DI (Dependency Injection) via parameter decorators, you have a problem. You’ll either have to stick with "experimental" mode for the foreseeable future or refactor toward class-level or field-level decorators. It’s a bitter pill, but the spec authors prefer a solid foundation over a feature-complete but broken one.
Truth #4: Decorators Finally Work with Private Members
One of the biggest blockers for the standard was the rise of #private fields in JavaScript. Legacy decorators had no idea how to handle them because they relied on property keys.
Standard decorators are designed with privacy in mind. Because the context.access object provides internal getters and setters, a decorator can safely interact with a private field without exposing it to the outside world.
function Trace(value, context) {
if (context.kind === "field" && context.private) {
return function (initialValue) {
console.log(`Initializing private field ${String(context.name)} with ${initialValue}`);
return initialValue;
};
}
}
class SecureVault {
@Trace
#secretCode = "12345";
}This was impossible before. You couldn’t decorate what you couldn't see. Now, the decorator is granted a specific, scoped "key" to that private member.
Truth #5: Performance is a Feature, Not an Afterthought
Legacy decorators were a performance trap. Because they were essentially arbitrary functions that could change the shape of an object (Hidden Classes in V8 terms), they often pushed objects into "dictionary mode," slowing down property access.
The new spec is designed to be "statically analyzable." This means the JS engine can look at a class with decorators and understand its final shape much earlier in the pipeline.
By returning specific types (a function for a method, an initializer for a field), you’re giving the engine a contract. You’re saying, "I am replacing this method with another method of the same shape." This allows JIT compilers to keep your code in the fast lane.
The Metadata Saga
If you’ve ever used reflect-metadata, you know that decorators are often used just to store info for later use (e.g., "this property should be validated as an email").
The standard proposal originally included a metadata API, but it was split off into its own proposal to prevent further delays. However, the current decorator spec *does* include a Symbol.metadata property.
When you decorate a class member, you can attach metadata to the class itself.
function Validate(schema) {
return function (value, context) {
context.metadata[context.name] = schema;
};
}
class User {
@Validate({ type: 'string', min: 3 })
username;
}
console.log(User[Symbol.metadata]);
// Output: { username: { type: 'string', min: 3 } }This is built-in. No polyfills (well, mostly), no Reflect global. It’s a clean, scoped way to handle reflection.
Handling the Transition
So, how do you move forward?
If you are a library author, you are entering a period of "The Great Split." You likely need to support both legacy and standard decorators. This is usually done by sniffing the arguments. If the second argument is an object (the context), it’s a standard decorator. If it’s a string (the key), it’s legacy.
function MyDecorator(value, contextOrKey, descriptor) {
if (typeof contextOrKey === "object") {
// Standard logic
return standardLogic(value, contextOrKey);
} else {
// Legacy logic
return legacyLogic(value, contextOrKey, descriptor);
}
}If you are an application developer: don't rush.
If your project is built on Angular 17 or NestJS, they are still heavily reliant on the experimental implementation. Switching your tsconfig.json to target: "ESNext" and disabling experimentalDecorators will break your world.
However, for new, vanilla JS or lean TS projects, start using the standard. Vite, Esbuild, and SWC already have great support for Stage 3 decorators.
Why This Matters
It’s easy to get frustrated with the churn of the JavaScript ecosystem. "Why change it if it worked?"
But the truth is, the legacy version *didn't* work well. It was a hack that we all agreed to pretend was standard. It created a rift between TypeScript and standard JavaScript that lasted for years.
The new standard bridges that gap. It gives us a way to write expressive, declarative code that is actually part of the language, not just a trick of the compiler. It’s more rigid, yes. It loses parameter decorators, for now. But in exchange, we get code that is faster, safer, and finally, truly "native."
The era of experimental decorators is ending. It's time to start writing code that the browser actually understands. It’s going to be a painful migration for some, but on the other side is a much more stable foundation for the next decade of JavaScript development.


