Skip to main content

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:

  1. target — The original method (or class, or field)
  2. 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​

FeatureOld (experimental)New (TS7 / TC39)
Flag requiredexperimentalDecorators: trueNone (built-in)
Method decorator args(target, propertyKey, descriptor)(target, context)
Class decorator args(constructor)(target, context)
Field decoratorsCan't access field valueCan return initializer
addInitializer❌✅
Spec stabilityExperimental, may changeTC39 standard

Migration​

If you're migrating from old decorators:

  1. Remove experimentalDecorators from tsconfig.json
  2. Rewrite decorator functions to use the new (target, context) signature
  3. Replace descriptor.value with the returned replacement function
  4. Use context.addInitializer instead of constructor wrapping

When to Use Decorators​

ScenarioUse 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​

  1. Write a @log decorator that logs method calls with their arguments and return values.
  2. Write a @timing decorator that measures and logs method execution time.
  3. Write a @deprecated decorator that logs a warning when a deprecated method is called.
  4. Write a @required field decorator that throws if the field is empty.
  5. Write a @cache decorator 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.