TypeScript Decorators Complete Guide: Patterns, Examples & Best Practices
Decorators let you add behavior to classes, methods, and properties using a clean @expression syntax — no invasive changes to your existing code. They power the dependency injection in NestJS, the component model in Angular, and the entity mapping in TypeORM. This guide covers every decorator type, shows you how to build your own for logging, validation, caching, and authentication, and explains the differences between legacy and Stage 3 decorators.
1. What Are Decorators and Why Use Them
A decorator is a function that receives information about a class element (a class itself, a method, a property, or a parameter) and can modify or replace it. Decorators use the @ prefix and are placed directly before the declaration they modify:
// A simple method decorator that logs every call
function Log(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${key} with`, args);
const result = original.apply(this, args);
console.log(`${key} returned`, result);
return result;
};
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3);
// Calling add with [2, 3]
// add returned 5
Without the decorator, you would need to manually wrap every method you want to log. Decorators let you separate cross-cutting concerns (logging, validation, caching, auth) from business logic. This is the same principle behind aspect-oriented programming (AOP), and it keeps your classes focused on what they do rather than how they are monitored or secured.
Common use cases for decorators:
- Logging and debugging method calls
- Input validation and type checking at runtime
- Authentication and authorization guards
- Caching expensive method results
- Dependency injection (NestJS, Angular)
- ORM entity and column mapping (TypeORM, MikroORM)
- API route definitions and middleware
- Rate limiting and throttling
2. Enabling Decorators in TypeScript
Legacy Decorators (experimentalDecorators)
Legacy decorators have been available since TypeScript 1.5 and are what NestJS, Angular, and TypeORM use. Enable them in your tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
The experimentalDecorators flag enables the @decorator syntax. The optional emitDecoratorMetadata flag tells TypeScript to emit type metadata using the reflect-metadata library, which is needed for dependency injection frameworks.
TC39 Stage 3 Decorators (TypeScript 5.0+)
TypeScript 5.0 introduced support for the TC39 Stage 3 decorator proposal. These do not need experimentalDecorators — they work by default. The API is different from legacy decorators:
// Stage 3 decorator -- no experimentalDecorators needed
function log(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`Calling ${methodName} with`, args);
return originalMethod.call(this, ...args);
}
return replacementMethod;
}
class Calculator {
@log
add(a: number, b: number): number { return a + b; }
}
Key difference: Legacy decorators receive (target, key, descriptor). Stage 3 decorators receive (value, context) where context includes the element's name, kind, and metadata access. This guide covers both approaches, with legacy decorators used in most examples since they remain dominant in production codebases.
3. Class Decorators
A class decorator is applied to the class constructor. It can observe, modify, or replace the class definition.
// Class decorator that seals the class (prevents adding new properties)
function Sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@Sealed
class UserService {
getUser(id: string) { return { id, name: "Alice" }; }
}
// Class decorator that adds a timestamp property
function Timestamped<T extends { new (...args: any[]): {} }>(Base: T) {
return class extends Base {
createdAt = new Date();
};
}
@Timestamped
class Order {
constructor(public id: string, public total: number) {}
}
const order = new Order("123", 59.99);
console.log((order as any).createdAt); // Date object
Replacing a Class with a Decorator
// Add a toString() method to any class
function WithToString(constructor: Function) {
constructor.prototype.toString = function () {
return JSON.stringify(this, null, 2);
};
}
// Register classes in a global registry
const registry = new Map<string, Function>();
function Register(name: string) {
return function (constructor: Function) {
registry.set(name, constructor);
};
}
@Register("user-service")
@WithToString
class UserService {
findAll() { return []; }
}
console.log(registry.get("user-service")); // [class UserService]
4. Method Decorators
Method decorators are the most commonly used type. They receive the target object, the method name, and the property descriptor, which lets you wrap or replace the method.
// Measure execution time
function Measure(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = performance.now();
const result = await original.apply(this, args);
const elapsed = (performance.now() - start).toFixed(2);
console.log(`${key} took ${elapsed}ms`);
return result;
};
}
// Bind method to instance (prevents 'this' loss)
function Bind(target: any, key: string, descriptor: PropertyDescriptor) {
return {
configurable: true,
get() {
const bound = descriptor.value.bind(this);
Object.defineProperty(this, key, { value: bound, configurable: true });
return bound;
}
};
}
class ApiClient {
@Measure
async fetchUsers(): Promise<User[]> {
const res = await fetch("/api/users");
return res.json();
}
@Bind
handleClick() {
console.log(this); // always the ApiClient instance
}
}
Error Handling Decorator
function Catch(handler: (error: Error, ctx: any) => void) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
try {
const result = original.apply(this, args);
if (result instanceof Promise) {
return result.catch((err: Error) => handler(err, this));
}
return result;
} catch (err) {
handler(err as Error, this);
}
};
};
}
class PaymentService {
@Catch((err, ctx) => console.error("Payment failed:", err.message))
async processPayment(amount: number) {
// if this throws, the Catch decorator handles it
const result = await chargeCard(amount);
return result;
}
}
5. Property Decorators
Property decorators receive the target and property name. They cannot directly access the property descriptor (unlike method decorators), but you can use Object.defineProperty to add getters/setters.
// Make a property read-only after initial assignment
function ReadOnly(target: any, key: string) {
let value: any;
Object.defineProperty(target, key, {
get: () => value,
set: (newVal) => {
if (value !== undefined) {
throw new Error(`Property ${key} is read-only`);
}
value = newVal;
},
enumerable: true,
configurable: false,
});
}
// Validate that a property is a positive number
function Positive(target: any, key: string) {
let value: number;
Object.defineProperty(target, key, {
get: () => value,
set: (newVal: number) => {
if (typeof newVal !== "number" || newVal <= 0) {
throw new Error(`${key} must be a positive number, got ${newVal}`);
}
value = newVal;
},
enumerable: true,
configurable: true,
});
}
class Product {
@ReadOnly
id: string;
@Positive
price: number;
constructor(id: string, price: number) {
this.id = id;
this.price = price;
}
}
const p = new Product("abc", 29.99);
// p.id = "xyz"; // Error: Property id is read-only
// p.price = -5; // Error: price must be a positive number
6. Parameter Decorators
Parameter decorators receive the target, method name, and parameter index. They are typically used to record metadata that other decorators or frameworks use at runtime.
import "reflect-metadata";
const REQUIRED_KEY = Symbol("required");
// Mark a parameter as required
function Required(target: any, methodName: string, paramIndex: number) {
const existing: number[] = Reflect.getOwnMetadata(REQUIRED_KEY, target, methodName) || [];
existing.push(paramIndex);
Reflect.defineMetadata(REQUIRED_KEY, existing, target, methodName);
}
// Validate that required parameters are not null/undefined
function Validate(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const requiredParams: number[] =
Reflect.getOwnMetadata(REQUIRED_KEY, target, key) || [];
for (const index of requiredParams) {
if (args[index] === null || args[index] === undefined) {
throw new Error(`Parameter at index ${index} in ${key}() is required`);
}
}
return original.apply(this, args);
};
}
class UserService {
@Validate
createUser(@Required name: string, @Required email: string, nickname?: string) {
return { name, email, nickname };
}
}
const svc = new UserService();
svc.createUser("Alice", "alice@example.com"); // OK
// svc.createUser("Alice", null as any); // Error: Parameter at index 1 is required
7. Accessor Decorators
Accessor decorators apply to getters and setters. They work like method decorators, receiving the property descriptor for the accessor.
function Enumerable(value: boolean) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
descriptor.enumerable = value;
};
}
class Config {
private _theme: string = "light";
@Enumerable(false)
get theme() { return this._theme; }
set theme(val: string) { this._theme = val; }
}
// Object.keys(new Config()) won't include 'theme' since enumerable is false
8. Decorator Factories
A decorator factory is a function that returns a decorator. This lets you pass configuration options to customize behavior.
// Factory: configurable logging with log level
function Log(level: "info" | "warn" | "debug" = "info") {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console[level](`[${level.toUpperCase()}] ${key}(`, ...args, ")");
return original.apply(this, args);
};
};
}
// Factory: retry failed async operations
function Retry(attempts: number = 3, delayMs: number = 1000) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
for (let i = 0; i < attempts; i++) {
try {
return await original.apply(this, args);
} catch (err) {
if (i === attempts - 1) throw err;
console.warn(`${key} failed (attempt ${i + 1}/${attempts}), retrying...`);
await new Promise(r => setTimeout(r, delayMs * (i + 1)));
}
}
};
};
}
class EmailService {
@Log("debug")
@Retry(3, 500)
async sendEmail(to: string, subject: string) {
const response = await fetch("/api/email", {
method: "POST",
body: JSON.stringify({ to, subject }),
});
if (!response.ok) throw new Error("Send failed");
return response.json();
}
}
Tip: For type-safe decorator factories, use TypedPropertyDescriptor<T> instead of PropertyDescriptor to restrict which method signatures the decorator can be applied to.
9. Decorator Composition and Execution Order
You can stack multiple decorators on a single declaration. Understanding execution order is critical when decorators depend on each other.
function First() {
console.log("First(): factory evaluated");
return function (target: any, key: string, descriptor: PropertyDescriptor) {
console.log("First(): decorator called");
};
}
function Second() {
console.log("Second(): factory evaluated");
return function (target: any, key: string, descriptor: PropertyDescriptor) {
console.log("Second(): decorator called");
};
}
class Example {
@First()
@Second()
method() {}
}
// Output:
// First(): factory evaluated -- factories run top-to-bottom
// Second(): factory evaluated
// Second(): decorator called -- decorators run bottom-to-top
// First(): decorator called
The rule: Factory expressions are evaluated top-to-bottom. The resulting decorator functions are called bottom-to-top. This matches mathematical function composition: @First @Second method is like First(Second(method)).
Within a class: instance member decorators run first, then static member decorators, then constructor parameter decorators, and finally the class decorator itself.
10. Metadata Reflection with reflect-metadata
The reflect-metadata library lets you attach and read metadata on classes and their members. Combined with emitDecoratorMetadata, TypeScript automatically emits type information that frameworks use for dependency injection.
import "reflect-metadata";
// TypeScript emits these metadata keys automatically:
// "design:type" -- property type
// "design:paramtypes" -- constructor/method parameter types
// "design:returntype" -- method return type
function Injectable(target: Function) {
// With emitDecoratorMetadata, we can read constructor param types
const paramTypes = Reflect.getMetadata("design:paramtypes", target);
console.log(`${target.name} depends on:`, paramTypes?.map((t: any) => t.name));
}
@Injectable
class UserController {
constructor(
private userService: UserService,
private logger: Logger,
) {}
}
// UserController depends on: ["UserService", "Logger"]
Custom Metadata
import "reflect-metadata";
// Define a custom metadata key for API routes
const ROUTE_KEY = Symbol("route");
function Route(method: string, path: string) {
return function (target: any, key: string) {
Reflect.defineMetadata(ROUTE_KEY, { method, path }, target, key);
};
}
function getRoutes(controller: any): Array<{ method: string; path: string; handler: string }> {
const prototype = controller.prototype;
return Object.getOwnPropertyNames(prototype)
.filter(key => Reflect.hasMetadata(ROUTE_KEY, prototype, key))
.map(key => ({
...Reflect.getMetadata(ROUTE_KEY, prototype, key),
handler: key,
}));
}
class UserController {
@Route("GET", "/users")
list() { /* ... */ }
@Route("POST", "/users")
create() { /* ... */ }
@Route("GET", "/users/:id")
findOne() { /* ... */ }
}
console.log(getRoutes(UserController));
// [{ method: "GET", path: "/users", handler: "list" },
// { method: "POST", path: "/users", handler: "create" },
// { method: "GET", path: "/users/:id", handler: "findOne" }]
11. Real-World Decorator Patterns
Caching Decorator
function Cache(ttlMs: number = 60000) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
const cache = new Map<string, { value: any; expiry: number }>();
descriptor.value = async function (...args: any[]) {
const cacheKey = JSON.stringify(args);
const cached = cache.get(cacheKey);
if (cached && Date.now() < cached.expiry) return cached.value;
const result = await original.apply(this, args);
cache.set(cacheKey, { value: result, expiry: Date.now() + ttlMs });
return result;
};
};
}
class ProductService {
@Cache(30000) // Cache for 30 seconds
async getProduct(id: string): Promise<Product> {
return fetch(`/api/products/${id}`).then(r => r.json());
}
}
Authentication Guard
function RequireAuth(role?: string) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const user = (this as any).currentUser;
if (!user) throw new Error("Authentication required");
if (role && user.role !== role) {
throw new Error(`Role '${role}' required, got '${user.role}'`);
}
return original.apply(this, args);
};
};
}
class AdminController {
currentUser: { name: string; role: string } | null = null;
@RequireAuth("admin")
deleteUser(userId: string) {
return { deleted: userId };
}
}
Rate Limiting
function RateLimit(maxCalls: number, windowMs: number) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
const calls: number[] = [];
descriptor.value = function (...args: any[]) {
const now = Date.now();
while (calls.length > 0 && calls[0] <= now - windowMs) calls.shift();
if (calls.length >= maxCalls) throw new Error("Rate limit exceeded");
calls.push(now);
return original.apply(this, args);
};
};
}
class ApiGateway {
@RateLimit(100, 60000) // 100 requests per minute
async handleRequest(req: any) { return processRequest(req); }
}
12. Decorators in Popular Frameworks
NestJS
NestJS uses decorators extensively for controllers, services, and dependency injection. Its decorator-first approach defines routing, validation, and middleware declaratively.
import { Controller, Get, Post, Body, Param, Injectable, UseGuards } from "@nestjs/common";
@Injectable()
class UserService {
async findAll(): Promise<User[]> { /* ... */ }
async findOne(id: string): Promise<User> { /* ... */ }
async create(data: CreateUserDto): Promise<User> { /* ... */ }
}
@Controller("users")
@UseGuards(AuthGuard)
class UserController {
constructor(private userService: UserService) {}
@Get()
findAll() { return this.userService.findAll(); }
@Get(":id")
findOne(@Param("id") id: string) { return this.userService.findOne(id); }
@Post()
create(@Body() data: CreateUserDto) { return this.userService.create(data); }
}
Angular
import { Component, Input, Injectable } from "@angular/core";
@Injectable({ providedIn: "root" })
class DataService {
getData() { return fetch("/api/data").then(r => r.json()); }
}
@Component({
selector: "app-user-card",
template: `<div class="card"><h2>{{ user.name }}</h2></div>`,
})
class UserCardComponent {
@Input() user!: User;
}
TypeORM
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from "typeorm";
@Entity()
class Article {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ length: 200 })
title: string;
@Column("text")
content: string;
@ManyToOne(() => User, user => user.articles)
author: User;
@CreateDateColumn()
createdAt: Date;
}
13. Stage 3 Decorator Proposal vs Legacy Decorators
TypeScript 5.0+ supports the TC39 Stage 3 decorator proposal alongside the legacy experimentalDecorators. Here are the key differences:
// LEGACY decorator (experimentalDecorators: true)
function LogLegacy(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`${key} called`);
return original.apply(this, args);
};
}
// STAGE 3 decorator (no flag needed, TS 5.0+)
function LogStage3(originalMethod: Function, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
console.log(`${String(context.name)} called`);
return originalMethod.apply(this, args);
};
}
| Feature | Legacy | Stage 3 |
|---|---|---|
| tsconfig flag | experimentalDecorators: true |
None needed |
| Parameters | (target, key, descriptor) |
(value, context) |
| Parameter decorators | Supported | Not supported |
| Metadata emission | emitDecoratorMetadata |
Uses context.metadata |
| Framework support | NestJS, Angular, TypeORM | Emerging (Lit, Stencil) |
| Future | Will remain supported | Will become the JS standard |
Which should you use? If you are working with NestJS, Angular, or TypeORM, use legacy decorators — those frameworks require them. For new libraries or projects that do not depend on framework-specific decorators, Stage 3 decorators are the forward-looking choice. Both can coexist in a project if needed, but a single file must use one or the other.
14. Best Practices, Pitfalls, and Performance
Best Practices
- Keep decorators focused: Each decorator should do one thing. Compose multiple decorators rather than building one that does everything.
- Name decorators clearly:
@Cacheable,@RequireAuth,@Validateare self-documenting. Avoid generic names like@Wrapper. - Use factories for configuration: If a decorator needs options, always create a factory:
@Cache(30000)instead of hard-coding values. - Preserve the original function signature: When wrapping methods, ensure the return type and
thiscontext are preserved. Useoriginal.apply(this, args). - Document decorator side effects: If a decorator modifies class behavior (adds properties, changes prototypes), document it clearly.
- Use TypedPropertyDescriptor: For type-safe method decorators, use
TypedPropertyDescriptor<(...args: X) => Y>instead ofPropertyDescriptor.
Common Pitfalls
// PITFALL 1: Decorators on arrow function class properties
class Bad {
// This does NOT work -- arrow properties are not on the prototype
@Log
myMethod = () => { /* ... */ };
// Use regular methods instead:
@Log
myMethod() { /* ... */ }
}
// PITFALL 2: Forgetting that decorators run at class definition, not instantiation
function Init(target: any, key: string) {
console.log("Decorator runs once when the class is loaded");
// NOT once per new instance
}
// PITFALL 3: Losing 'this' context in wrapped methods
function BadDecorator(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
// BAD: arrow function captures wrong 'this'
descriptor.value = (...args: any[]) => original(...args);
// GOOD: regular function preserves 'this'
descriptor.value = function (...args: any[]) { return original.apply(this, args); };
}
Performance Considerations
- Decorator application is one-time: Decorators run when the class is defined, not on each method call. The cost is negligible for class/property/parameter decorators.
- Method wrappers add overhead: Each wrapping decorator adds a function call per invocation. For hot paths (tight loops, high-frequency events), this can add up. Profile before assuming it matters.
- Caching decorators can improve performance: A well-implemented
@Cachedecorator on expensive database or API calls can dramatically reduce response times. - Avoid heavy logic in decorators: Keep decorator code lightweight. Move complex initialization to lazy patterns or factory methods.
- Metadata reflection has a cost:
Reflect.getMetadatalookups are fast but not free. Avoid calling them in hot paths; read metadata once during initialization.
For a deeper understanding of the TypeScript type system that underpins decorators, see our TypeScript Generics Complete Guide and TypeScript Utility Types Guide. If you are building full-stack applications, our Next.js Complete Guide covers how decorator-like patterns appear in modern React frameworks.
Frequently Asked Questions
What are TypeScript decorators and when should I use them?
Decorators are special functions that modify or annotate classes, methods, properties, and parameters using the @expression syntax. Use them for cross-cutting concerns like logging, validation, authentication, and caching. They are especially powerful in frameworks like NestJS and Angular for dependency injection, routing, and component configuration.
What is the difference between legacy and Stage 3 decorators?
Legacy decorators require experimentalDecorators: true and use the (target, key, descriptor) signature. Stage 3 decorators (TypeScript 5.0+) need no flag and use (value, context). Stage 3 does not support parameter decorators. Legacy decorators are used by NestJS, Angular, and TypeORM; Stage 3 will become the JavaScript standard.
How do I enable decorators in TypeScript?
For legacy decorators, set "experimentalDecorators": true in tsconfig.json. Add "emitDecoratorMetadata": true if you need reflect-metadata for DI frameworks. Stage 3 decorators (TS 5.0+) work without any flag.
What order do multiple decorators execute?
Decorator factory expressions evaluate top-to-bottom, but the resulting decorator functions execute bottom-to-top. This follows function composition: @A @B method is like A(B(method)). Within a class, member decorators run before the class decorator.
Do decorators affect runtime performance?
Decorator application is one-time at class definition and has negligible cost. Method-wrapping decorators add one function call per invocation. For most applications this is insignificant, but avoid stacking many wrappers on hot-path methods. Profile before optimizing.
Can I use decorators with arrow functions or standalone functions?
No. Decorators only work on class declarations, methods, accessors, properties, and parameters. For standalone functions, use higher-order functions: const loggedFn = withLogging(myFn). Arrow function class properties also do not support decorators — use regular methods instead.
Related Resources
- TypeScript Generics Complete Guide — master the type system that powers decorator type safety
- TypeScript Utility Types Guide — deep dive into Partial, Pick, Omit, Record, and more
- Next.js Complete Guide — full-stack React with decorator-like patterns
- JSON Formatter — format and validate JSON for TypeScript APIs
Keep learning: Decorators become even more powerful when combined with TypeScript's generic type system. Read our TypeScript Generics Complete Guide to understand the type-level patterns that make decorator factories fully type-safe. Use the JSON Formatter to validate API payloads that your decorator-based validation will enforce at runtime.