TypeScript 5+ Decorators: The Complete Guide to ECMAScript Decorators
TypeScript 5.0 introduced standardized ECMAScript decorators, replacing the legacy experimental decorators with the official TC39 Stage 3 proposal. This guide covers everything you need to know about modern decorator syntax, patterns, and best practices.
What Are Decorators?
Decorators are functions that can modify or enhance classes, methods, properties, and accessors at design time. They provide a declarative way to add cross-cutting concerns like logging, validation, caching, and dependency injection.
Decorator Context Objects
All decorators receive a context object with metadata about the decorated element:
interface ClassDecoratorContext {
readonly kind: "class";
readonly name: string | undefined;
readonly addInitializer(initializer: () => void): void;
}
interface ClassMethodDecoratorContext {
readonly kind: "method";
readonly name: string;
readonly static: boolean;
readonly private: boolean;
readonly access: { get(): unknown; set(value: unknown): void } | undefined;
readonly addInitializer(initializer: () => void): void;
}
interface ClassFieldDecoratorContext {
readonly kind: "field";
readonly name: string;
readonly static: boolean;
readonly private: boolean;
readonly access: { get(): unknown; set(value: unknown): void };
}
interface ClassAccessorDecoratorContext {
readonly kind: "accessor";
readonly name: string;
readonly static: boolean;
readonly private: boolean;
readonly access: { get(): unknown; set(value: unknown): void };
}
Class Decorators
Class decorators modify entire classes and receive the constructor function:
function sealed(constructor: Function, context: ClassDecoratorContext) {
console.log(\`Sealing class \${context.name}\`);
Object.seal(constructor);
Object.seal(constructor.prototype);
}
function version(versionNumber: string) {
return function(constructor: Function, context: ClassDecoratorContext) {
context.addInitializer(() => {
(constructor as any).version = versionNumber;
});
};
}
@sealed
@version("1.0.0")
class MyClass {
// Implementation
}
Method Decorators
Method decorators can replace, wrap, or modify methods:
function logged(originalMethod: Function, context: ClassMethodDecoratorContext) {
return function(this: any, ...args: any[]) {
console.log(\`Calling \${String(context.name)} with\`, args);
const result = originalMethod.apply(this, args);
console.log(\`\${String(context.name)} returned\`, result);
return result;
};
}
function measure(originalMethod: Function, context: ClassMethodDecoratorContext) {
return function(this: any, ...args: any[]) {
const start = performance.now();
const result = originalMethod.apply(this, args);
const end = performance.now();
console.log(\`\${String(context.name)} took \${end - start}ms\`);
return result;
};
}
class Calculator {
@logged
@measure
add(a: number, b: number): number {
return a + b;
}
}
Property (Field) Decorators
Field decorators control property access and modification:
function range(min: number, max: number) {
return function(initialValue: undefined, context: ClassFieldDecoratorContext) {
return function(this: any, newValue: number) {
if (newValue < min || newValue > max) {
throw new Error(\`Value must be between \${min} and \${max}\`);
}
return newValue;
};
};
}
function readonly(initialValue: any, context: ClassFieldDecoratorContext) {
return function(this: any, newValue: any) {
if (context.access.get() !== undefined) {
throw new Error(\`Cannot modify readonly property \${String(context.name)}\`);
}
return newValue;
};
}
class Product {
@range(0, 1000)
price: number = 0;
@readonly
readonly id: string = "123";
}
Accessor Decorators (Getters/Setters)
Accessor decorators work with traditional getters and setters:
function enumerable(value: boolean) {
return function(accessor: { get(): any; set(value: any): void }, context: ClassAccessorDecoratorContext) {
context.addInitializer(() => {
Object.defineProperty(context.access.get, 'enumerable', { value });
});
};
}
class Person {
private _name: string = "";
@enumerable(false)
get name(): string {
return this._name;
}
set name(value: string) {
this._name = value.trim();
}
}
Auto-Accessors (TypeScript 5.4+)
Auto-accessors use the accessor keyword and automatically generate getters/setters:
function logged(target: any, context: ClassAccessorDecoratorContext) {
return {
get() {
console.log(\`Getting \${String(context.name)}\`);
return target.get.call(this);
},
set(value: any) {
console.log(\`Setting \${String(context.name)} to\`, value);
return target.set.call(this, value);
}
};
}
class Person {
@logged
accessor name: string;
constructor(name: string) {
this.name = name;
}
}
// Usage
const person = new Person("Alice");
console.log(person.name); // "Getting name" then "Alice"
person.name = "Bob"; // "Setting name to Bob"
Advanced Patterns
Dependency Injection Container
type ServiceMap = Map<string, any>;
function injectable(serviceName: string) {
return function(constructor: Function, context: ClassDecoratorContext) {
context.addInitializer(() => {
container.set(serviceName, constructor);
});
};
}
function inject(serviceName: string) {
return function(initialValue: undefined, context: ClassFieldDecoratorContext) {
return function(this: any) {
return container.get(serviceName);
};
};
}
const container: ServiceMap = new Map();
@injectable('logger')
class Logger {
log(message: string) {
console.log(\`[LOG] \${message}\`);
}
}
class UserService {
@inject('logger')
readonly logger!: Logger;
createUser(name: string) {
this.logger.log(\`Creating user: \${name}\`);
// Implementation
}
}
API Route Validation with Zod
import { z } from 'zod';
function validate(schema: z.ZodSchema) {
return function(originalMethod: Function, context: ClassMethodDecoratorContext) {
return async function(this: any, ...args: any[]) {
const [request] = args;
if (request instanceof Request) {
const body = await request.json();
const result = schema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: result.error.format() },
{ status: 400 }
);
}
return originalMethod.call(this, result.data);
}
return originalMethod.apply(this, args);
};
};
}
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18)
});
class UserController {
@validate(userSchema)
async createUser(data: z.infer<typeof userSchema>) {
// Data is now fully typed and validated
return { id: Date.now(), ...data };
}
}
Caching Decorator
function memoize(ttlMs: number = 5000) {
return function(originalMethod: Function, context: ClassMethodDecoratorContext) {
const cache = new Map<string, { value: any; expiry: number }>();
return function(this: any, ...args: any[]) {
const key = JSON.stringify(args);
const cached = cache.get(key);
const now = Date.now();
if (cached && cached.expiry > now) {
return cached.value;
}
const result = originalMethod.apply(this, args);
cache.set(key, { value: result, expiry: now + ttlMs });
return result;
};
};
}
class ApiService {
@memoize(10000) // Cache for 10 seconds
async fetchUser(id: string) {
const response = await fetch(\`/api/users/\${id}\`);
return response.json();
}
}
Event Emitter Mixin
type EventMap = Record<string, any[]>;
function eventEmitter<T extends EventMap>() {
return function(constructor: Function, context: ClassDecoratorContext) {
context.addInitializer(() => {
const listeners = new Map<keyof T, Set<Function>>();
(constructor as any).prototype.addEventListener = function<K extends keyof T>(
event: K,
listener: (...args: T[K]) => void
) {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event)!.add(listener);
};
(constructor as any).prototype.removeEventListener = function<K extends keyof T>(
event: K,
listener: (...args: T[K]) => void
) {
listeners.get(event)?.delete(listener);
};
(constructor as any).prototype.emit = function<K extends keyof T>(
event: K,
...args: T[K]
) {
listeners.get(event)?.forEach(listener => listener(...args));
};
});
};
}
interface UserEvents {
login: [user: User];
logout: [];
}
@eventEmitter<UserEvents>()
class AuthService {
private currentUser: User | null = null;
async login(credentials: Credentials) {
const user = await api.login(credentials);
this.currentUser = user;
this.emit('login', user);
}
logout() {
this.currentUser = null;
this.emit('logout');
}
}
Migration from Experimental Decorators
Breaking Changes
- No parameter decorators: Parameter decorators are not supported in the new standard
- No
emitDecoratorMetadata: The metadata emission system has changed - Different context objects: New context API with
kind,name,static, etc. - No decorator factories for parameters: Only direct decorator application
Migration Steps
- Update TypeScript configuration:
{
"compilerOptions": {
"experimentalDecorators": false,
"emitDecoratorMetadata": false,
"target": "ES2022"
}
}
- Update decorator signatures:
// Old (experimental)
function myDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor)
// New (standard)
function myDecorator(value: any, context: ClassMethodDecoratorContext) {
// Return replacement function or undefined
}
- Replace parameter decorators:
// Old - not supported
class MyClass {
method(@validate param: string) {}
}
// New - validate in method body
class MyClass {
method(param: string) {
validate(param);
}
}
- Update metadata access:
// Old
function inject(serviceName: string) {
return function(target: any, propertyKey: string) {
const type = Reflect.getMetadata("design:type", target, propertyKey);
};
}
// New
function inject(serviceName: string) {
return function(value: undefined, context: ClassFieldDecoratorContext) {
// Context provides type information
};
}
Best Practices
1. Use Appropriate Decorator Types
- Class decorators: For class-level modifications (sealing, metadata)
- Method decorators: For behavior modification (logging, caching)
- Field decorators: For property validation and transformation
- Accessor decorators: For computed properties with side effects
2. Keep Decorators Pure
// Good - pure decorator
function log(level: 'info' | 'error') {
return function(originalMethod: Function, context: ClassMethodDecoratorContext) {
return function(...args: any[]) {
console[level](\`Calling \${String(context.name)}\`);
return originalMethod.apply(this, args);
};
};
}
// Avoid - impure decorator
let callCount = 0;
function countCalls(originalMethod: Function, context: ClassMethodDecoratorContext) {
return function(...args: any[]) {
callCount++; // Side effect!
return originalMethod.apply(this, args);
};
}
3. Handle Static Members Properly
function staticOnly(originalMethod: Function, context: ClassMethodDecoratorContext) {
if (!context.static) {
throw new Error('This decorator can only be used on static methods');
}
// Implementation
}
4. Use Context Initializers Wisely
function register(constructor: Function, context: ClassDecoratorContext) {
context.addInitializer(() => {
registry.set(context.name!, constructor);
});
}
5. Type Safety First
function apiEndpoint(path: string) {
return function <T extends { new (...args: any[]): {} }>(
constructor: T,
context: ClassDecoratorContext
) {
return class extends constructor {
static readonly path = path;
// Additional API endpoint logic
};
};
}
Conclusion
TypeScript 5+ decorators provide a powerful, standardized way to enhance your classes and their members. The new API is more predictable, type-safe, and aligned with the ECMAScript standard.
Start by replacing simple logging decorators, then gradually adopt more complex patterns like validation, caching, and dependency injection. The context-based API makes decorators more powerful and easier to reason about.
Remember: decorators are design-time tools - they modify how your classes work, not runtime behavior. Use them judiciously to keep your code maintainable and your intentions clear.
What decorator patterns are you most excited to implement? Share your experiences and patterns in the comments!