Decorators used to be experimental. You had to opt in with experimentalDecorators: true. You had to worry about the spec changing. You had to choose between the TypeScript flavor and the TC39 proposal flavor. And if you picked wrong, your code would break in a future version.
Not anymore. TypeScript 7 ships with the stable, TC39-standard decorators specification. No flags. No warnings. No uncertainty. Decorators are now a first-class, production-ready feature of the language.
This chapter shows you what the new decorators look like, how they differ from the old ones, and how to use them for real-world metaprogramming.
What Decorators Are
A decorator is a function that modifies a class, method, property, or parameter at definition time. Think of it as a wrapper that adds behavior without changing the original code:
// Without decorators
class UserService {
log(method: string) {
console.log(`Calling ${method}`);
}
getUser(id: number) {
this.log("getUser");
// ... actual logic
}
createUser(data: CreateUserDto) {
this.log("createUser");
// ... actual logic
}
}
// With decorators
class UserService {
@log
getUser(id: number) {
// ... just the logic
}
@log
createUser(data: CreateUserDto) {
// ... just the logic
}
}
The @log decorator extracts the logging concern from the method body. The method focuses on business logic. The decorator handles the cross-cutting concern.
The New Decorator Syntax
TypeScript 7 uses the TC39 decorator specification. Here are the key differences from the old experimental decorators:
Method Decorators
function log<T>(
target: Function,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: T, ...args: unknown[]) {
console.log(`Calling ${methodName} with`, args);
const result = target.call(this, ...args);
console.log(`${methodName} returned`, result);
return result;
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
The decorator receives:
target— The original method (or class, or field)context— Metadata about what's being decorated (name, kind, access, etc.)
It returns a replacement function (or undefined to leave the original unchanged).
Class Decorators
function sealed<T extends new (...args: unknown[]) => unknown>(
target: T,
context: ClassDecoratorContext
) {
// Prevent extensions
Object.seal(target);
Object.seal(target.prototype);
return target;
}
@sealed
class Config {
apiUrl = "https://api.example.com";
timeout = 5000;
}
Field Decorators
function validate<T>(
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: T, value: unknown) {
// Called when the field is set
console.log(`Setting ${String(context.name)} to`, value);
return value;
};
}
class Form {
@validate
name: string = "";
@validate
email: string = "";
}
Getter/Setter Decorators
function memoize<T, V>(
target: (this: T) => V,
context: ClassGetterDecoratorContext<T, V>
) {
const cache = new Map<string, V>();
return function (this: T): V {
const key = String(context.name);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = target.call(this);
cache.set(key, result);
return result;
};
}
class DataService {
@memoize
get expensiveComputation(): number {
// This only runs once — the result is cached
return computeSomethingExpensive();
}
}
The Context Object
The context object provides metadata about the decorated element:
interface ClassMethodDecoratorContext {
kind: "method"; // What kind of element
name: string | symbol; // The element's name
static: boolean; // Is it static?
private: boolean; // Is it private?
access: { // Access the element
get(): unknown;
set(value: unknown): void;
};
addInitializer(fn: () => void): void; // Run code after the class is defined
}
addInitializer
The addInitializer method lets you run code after the class is fully defined:
function register(target: Function, context: ClassDecoratorContext) {
context.addInitializer(function () {
// 'this' is the class itself
registry.register(this);
});
}
@register
class MyComponent {
// After this class is defined, it's automatically registered
}
Real-World Decorator Patterns
Pattern 1: Logging
function log(level: "debug" | "info" | "warn" = "info") {
return function <T>(
target: Function,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: T, ...args: unknown[]) {
const start = performance.now();
console[level](`[${methodName}] Called with:`, args);
try {
const result = target.call(this, ...args);
const duration = performance.now() - start;
console[level](`[${methodName}] Returned in ${duration.toFixed(2)}ms:`, result);
return result;
} catch (error) {
console[level](`[${methodName}] Failed:`, error);
throw error;
}
};
};
}
class UserService {
@log("debug")
async getUser(id: number): Promise<User> {
return db.users.findById(id);
}
}
Pattern 2: Validation
function required(
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: Record<string, unknown>, value: unknown) {
if (value === undefined || value === null || value === "") {
throw new Error(`${String(context.name)} is required`);
}
return value;
};
}
function minLength(length: number) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: Record<string, unknown>, value: string) {
if (value.length < length) {
throw new Error(
`${String(context.name)} must be at least ${length} characters`
);
}
return value;
};
};
}
class CreateUserDto {
@required
@minLength(2)
name: string = "";
@required
email: string = "";
@minLength(8)
password: string = "";
}
Pattern 3: Dependency Injection
const INJECTABLE = Symbol("injectable");
function Injectable() {
return function (target: new (...args: unknown[]) => unknown, context: ClassDecoratorContext) {
Reflect.defineMetadata(INJECTABLE, true, target);
return target;
};
}
function Inject(token?: unknown) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
context.addInitializer(function () {
// Store injection metadata
const injections = Reflect.getMetadata("design:paramtypes", target) || [];
Reflect.defineMetadata("inject:tokens", injections, target.constructor);
});
};
}
@Injectable()
class UserService {
@Inject()
private database!: Database;
@Inject()
private logger!: Logger;
getUser(id: number): User {
this.logger.info(`Fetching user ${id}`);
return this.database.users.findById(id);
}
}
Pattern 4: Rate Limiting
function rateLimit(maxCalls: number, windowMs: number) {
return function <T>(
target: Function,
context: ClassMethodDecoratorContext
) {
const calls: number[] = [];
return function (this: T, ...args: unknown[]) {
const now = Date.now();
// Remove calls outside the window
while (calls.length > 0 && calls[0] < now - windowMs) {
calls.shift();
}
if (calls.length >= maxCalls) {
throw new Error(`Rate limit exceeded: ${maxCalls} calls per ${windowMs}ms`);
}
calls.push(now);
return target.call(this, ...args);
};
};
}
class ApiClient {
@rateLimit(10, 1000) // 10 calls per second
async fetchData(endpoint: string): Promise<Response> {
return fetch(endpoint);
}
}
Old vs. New Decorators
| Feature | Old (experimental) | New (TS7 / TC39) |
|---|---|---|
| Flag required | experimentalDecorators: true | None (built-in) |
| Method decorator args | (target, propertyKey, descriptor) | (target, context) |
| Class decorator args | (constructor) | (target, context) |
| Field decorators | Can't access field value | Can return initializer |
addInitializer | ❌ | ✅ |
| Spec stability | Experimental, may change | TC39 standard |
Migration
If you're migrating from old decorators:
- Remove
experimentalDecoratorsfromtsconfig.json - Rewrite decorator functions to use the new
(target, context)signature - Replace
descriptor.valuewith the returned replacement function - Use
context.addInitializerinstead of constructor wrapping
When to Use Decorators
| Scenario | Use Decorator? |
|---|---|
| Logging/tracing | ✅ Yes |
| Validation | ✅ Yes |
| Memoization/caching | ✅ Yes |
| Authorization checks | ✅ Yes |
| Rate limiting | ✅ Yes |
| Dependency injection | ✅ Yes (frameworks) |
| Simple property assignment | ❌ Just write the code |
| Business logic | ❌ Keep it in the method |
Decorators are for cross-cutting concerns — things that apply across many methods or classes. If a behavior is specific to one method, put it in the method body.
Try This: Decorators
- Write a
@logdecorator that logs method calls with their arguments and return values. - Write a
@timingdecorator that measures and logs method execution time. - Write a
@deprecateddecorator that logs a warning when a deprecated method is called. - Write a
@requiredfield decorator that throws if the field is empty. - Write a
@cachedecorator that memoizes getter results.
Time needed: 25 minutes.
What to notice: How decorators extract cross-cutting concerns from method bodies. How the context object provides metadata about the decorated element. How addInitializer lets you run setup code after class definition. How decorators compose — you can stack multiple decorators on a single element.
The Revelation
You now know how to use TypeScript 7's stable decorators. Combined with pattern matching from the last chapter, you have two of the most powerful new features in the language.
But there's a smaller feature that might have an even bigger impact on your day-to-day code: const type parameters. They eliminate the need for as const in most cases, making generic inference smarter and your code cleaner.
In the next chapter: const type parameters, improved generic inference, and how TypeScript 7 makes the compiler smart enough to infer literal types without as const.