JavaScript Decorators: Stage 3 Proposal Guide
JavaScript decorators provide a declarative way to modify classes and their members. Think of them as special functions that wrap or transform class methods, fields, accessors, and the classes...
Key Insights
- Stage 3 decorators are fundamentally different from legacy decorators—they receive a context object and return replacement values rather than mutating targets directly, making them more predictable and composable.
- Method decorators excel at cross-cutting concerns like logging and caching, while class decorators enable powerful patterns like dependency injection and sealed classes without runtime prototype manipulation.
- Browser support is still limited, but Babel and TypeScript 5.0+ provide production-ready implementations that let you use decorators today with proper transpilation configuration.
Introduction to Decorators
JavaScript decorators provide a declarative way to modify classes and their members. Think of them as special functions that wrap or transform class methods, fields, accessors, and the classes themselves. Instead of manually wrapping functions or using inheritance chains, decorators let you apply reusable behaviors with clean, readable syntax.
The decorator proposal has been in development since 2014, going through multiple iterations. The current Stage 3 proposal represents a significant departure from the legacy Stage 2 decorators that TypeScript and Babel users have been using for years. The key difference: Stage 3 decorators are non-mutating. They don’t modify the target directly; instead, they return replacement values.
Here’s what decorators look like in practice:
// Without decorator
class UserService {
getUser(id) {
console.log(`Fetching user ${id}`);
return fetch(`/api/users/${id}`);
}
}
// With decorator
class UserService {
@log
getUser(id) {
return fetch(`/api/users/${id}`);
}
}
The @log decorator wraps the method, adding logging behavior without cluttering the core business logic. This separation of concerns is what makes decorators powerful.
Class Method Decorators
Method decorators intercept method calls, allowing you to wrap functionality around the original implementation. A method decorator receives two arguments: the original method and a context object containing metadata.
The basic structure looks like this:
function log(originalMethod, context) {
const methodName = context.name;
return function(...args) {
console.log(`[${methodName}] Called with:`, args);
const result = originalMethod.call(this, ...args);
console.log(`[${methodName}] Returned:`, result);
return result;
};
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
const calc = new Calculator();
calc.add(5, 3);
// [add] Called with: [5, 3]
// [add] Returned: 8
A more practical example is a debounce decorator for rate-limiting method calls:
function debounce(delay) {
return function(originalMethod, context) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
originalMethod.call(this, ...args);
}, delay);
};
};
}
class SearchBox {
@debounce(300)
onSearch(query) {
console.log(`Searching for: ${query}`);
// API call here
}
}
Notice how debounce is a decorator factory—it returns the actual decorator. This pattern lets you parameterize decorator behavior.
Class Field and Accessor Decorators
Field decorators work differently from method decorators. They can intercept field initialization and provide custom getter/setter behavior. The Stage 3 proposal introduces auto-accessors using the accessor keyword, which creates a private backing field with public getter/setter.
Here’s a @readonly decorator that prevents field modification after initialization:
function readonly(value, context) {
return {
get() {
return value;
},
set(newValue) {
throw new Error(`Cannot modify readonly field: ${context.name}`);
}
};
}
class Configuration {
accessor @readonly apiKey = process.env.API_KEY;
}
const config = new Configuration();
console.log(config.apiKey); // Works
config.apiKey = 'new-key'; // Throws error
For validation, you can create decorators that check values before assignment:
function validate(validator) {
return function(value, context) {
return {
get() {
return value;
},
set(newValue) {
if (!validator(newValue)) {
throw new Error(`Invalid value for ${context.name}: ${newValue}`);
}
value = newValue;
}
};
};
}
class User {
accessor @validate(email => email.includes('@')) email;
}
const user = new User();
user.email = 'test@example.com'; // Works
user.email = 'invalid'; // Throws error
Class Decorators
Class decorators receive the class constructor and can return a replacement class or modify the original. They’re perfect for class-level transformations like sealing, registration, or adding static properties.
A @sealed decorator prevents class extension:
function sealed(originalClass, context) {
return class extends originalClass {
constructor(...args) {
super(...args);
Object.seal(this);
}
};
}
@sealed
class FinalClass {
value = 42;
}
const instance = new FinalClass();
instance.newProp = 'test'; // Fails silently in non-strict mode, throws in strict
Class decorators enable powerful registration patterns:
const registry = new Map();
function register(name) {
return function(originalClass, context) {
registry.set(name, originalClass);
return originalClass;
};
}
@register('userService')
class UserService {
getUsers() { /* ... */ }
}
// Later, retrieve registered classes
const ServiceClass = registry.get('userService');
const service = new ServiceClass();
Decorator Composition and Context
You can stack multiple decorators, and they execute in a specific order: bottom to top for evaluation, top to bottom for execution. This matters when decorators depend on each other.
function authorize(requiredRole) {
return function(originalMethod, context) {
return function(...args) {
if (!this.user || this.user.role !== requiredRole) {
throw new Error('Unauthorized');
}
return originalMethod.call(this, ...args);
};
};
}
function cache(originalMethod, context) {
const cacheMap = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cacheMap.has(key)) {
console.log('Cache hit');
return cacheMap.get(key);
}
const result = originalMethod.call(this, ...args);
cacheMap.set(key, result);
return result;
};
}
class DataService {
user = { role: 'admin' };
@authorize('admin') // Executes first
@cache // Executes second
@log // Executes third
fetchData(id) {
return { id, data: 'sensitive' };
}
}
The context object provides valuable metadata:
function inspect(target, context) {
console.log('Kind:', context.kind); // 'method', 'field', 'class', etc.
console.log('Name:', context.name);
console.log('Private:', context.private);
console.log('Static:', context.static);
return target;
}
Practical Applications and Patterns
Decorators shine in framework development and cross-cutting concerns. Here’s a dependency injection system using decorators:
const dependencies = new Map();
function injectable(originalClass, context) {
dependencies.set(context.name, originalClass);
return originalClass;
}
function inject(serviceName) {
return function(value, context) {
return {
get() {
const ServiceClass = dependencies.get(serviceName);
if (!ServiceClass) {
throw new Error(`Service not found: ${serviceName}`);
}
return new ServiceClass();
}
};
};
}
@injectable
class Logger {
log(msg) { console.log(msg); }
}
@injectable
class UserService {
accessor @inject('Logger') logger;
getUser(id) {
this.logger.log(`Fetching user ${id}`);
return { id, name: 'John' };
}
}
const service = new UserService();
service.getUser(1); // Automatically injects Logger
Performance considerations: Decorators add minimal overhead, but be cautious with decorators that create closures or cache data. Each decorated method creates a new function wrapper, which can impact memory if you’re creating thousands of instances.
Tooling support: TypeScript 5.0+ supports Stage 3 decorators with the experimentalDecorators flag set to false. For Babel, use @babel/plugin-proposal-decorators with version: "2023-05".
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": false,
"target": "ES2022"
}
}
Migration Guide and Best Practices
Migrating from legacy decorators requires understanding the fundamental differences. Legacy decorators mutated their targets; Stage 3 decorators return replacements. This means:
Legacy pattern:
function legacy(target, key, descriptor) {
const original = descriptor.value;
descriptor.value = function(...args) {
console.log('Called');
return original.apply(this, args);
};
return descriptor;
}
Stage 3 pattern:
function stage3(originalMethod, context) {
return function(...args) {
console.log('Called');
return originalMethod.call(this, ...args);
};
}
Common pitfalls:
- Forgetting
thisbinding: Always use.call(this, ...)when invoking the original method - Decorator ordering confusion: Remember bottom-to-top evaluation
- Overusing decorators: Not everything needs a decorator—sometimes a simple function is clearer
When to use decorators:
- Cross-cutting concerns (logging, timing, authorization)
- Framework-level abstractions (routing, dependency injection)
- Metadata attachment and reflection
When NOT to use decorators:
- Core business logic (keep it explicit)
- One-off transformations (just write a function)
- When it reduces code clarity
The Stage 3 proposal is stable and likely to reach Stage 4 in 2024. Browser implementations are underway, with polyfills and transpilers providing production-ready solutions today. Start using decorators now to write cleaner, more maintainable code—just ensure your build pipeline supports them.