4.4 Decorators
A decorator is a metaprogramming tool attached in the form @expression to a class, method, property, or parameter to add or transform behavior. Because decorators let you inject functionality from the outside without modifying the code itself, frameworks like NestJS (@Controller), TypeORM (@Entity), and Angular (@Component) use them as a core mechanism.
This chapter clearly distinguishes between TypeScript 5.x standard decorators (TC39 Stage 3) and the legacy experimentalDecorators approach, and teaches you how to implement practical decorators yourself.
What Are Decorators? — Stage 3 vs experimentalDecorators
Historical background
TypeScript has long supported decorators via the experimentalDecorators: true option. This approach is based on the TC39 Stage 2 proposal, and Angular, NestJS, and TypeORM all use it.
In 2023, TC39 advanced a new standard decorator proposal to Stage 3, and TypeScript 5.0 officially supports this standard. The two approaches have different APIs and cannot be mixed.
// tsconfig.json — legacy approach (NestJS, Angular, etc.)
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
// tsconfig.json — standard approach (TypeScript 5.0+)
// Just use them; no experimentalDecorators option needed
This chapter is based on TypeScript 5.x standard decorators and calls out differences from the legacy approach separately.
Class Decorators
A class decorator is attached directly above a class declaration. It receives the class itself as an argument and can transform or wrap it.
TypeScript 5.x Standard Class Decorator
// Standard decorator type (TypeScript 5.0+)
type ClassDecorator = (
target: Function,
context: ClassDecoratorContext
) => Function | void;
// Simple class decorator: seal the class
function Sealed(target: Function, context: ClassDecoratorContext) {
Object.seal(target);
Object.seal(target.prototype);
console.log(`Class ${context.name} has been sealed.`);
}
@Sealed
class Config {
host: string = "localhost";
port: number = 3000;
}
// Config.newProp = "test"; // Error: cannot add properties to a sealed object
Metadata-Injection Decorator
// A decorator that injects singleton behavior into a class
function Singleton<T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext
): T {
let instance: InstanceType<T> | null = null;
// Return a new class that wraps the original
const Wrapped = class extends target {
constructor(...args: any[]) {
if (instance) return instance;
super(...args);
instance = this as InstanceType<T>;
}
} as T;
return Wrapped;
}
@Singleton
class AppState {
value: number = 0;
increment(): void {
this.value++;
}
}
const s1 = new AppState();
const s2 = new AppState();
s1.increment();
console.log(s1 === s2); // true
console.log(s2.value); // 1 (same instance)
Method Decorators
A method decorator is attached above a method declaration and can add logic before or after the method executes, or replace the method entirely.
Standard Method Decorator
// Method decorator signature (TypeScript 5.x)
type MethodDecorator<This, Args extends any[], Return> = (
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) => ((this: This, ...args: Args) => Return) | void;
Logging Decorator
function Log(
target: Function,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: unknown, ...args: unknown[]) {
console.log(`[LOG] ${methodName} called — args:`, args);
const start = performance.now();
const result = (target as Function).apply(this, args);
// Handle Promises
if (result instanceof Promise) {
return result
.then((value) => {
const elapsed = (performance.now() - start).toFixed(2);
console.log(`[LOG] ${methodName} done (${elapsed}ms) — returned:`, value);
return value;
})
.catch((err) => {
console.error(`[LOG] ${methodName} error:`, err);
throw err;
});
}
const elapsed = (performance.now() - start).toFixed(2);
console.log(`[LOG] ${methodName} done (${elapsed}ms) — returned:`, result);
return result;
};
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
@Log
async fetchRate(currency: string): Promise<number> {
// Actual implementation would call an API
await new Promise((r) => setTimeout(r, 100));
return currency === "USD" ? 1.0 : 0.85;
}
}
const calc = new Calculator();
calc.add(3, 4);
// [LOG] add called — args: [3, 4]
// [LOG] add done (0.xx ms) — returned: 7
Memoize Decorator
function Memoize(
target: Function,
context: ClassMethodDecoratorContext
) {
const cache = new Map<string, unknown>();
return function (this: unknown, ...args: unknown[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`[Memoize] Cache hit: ${String(context.name)}(${key})`);
return cache.get(key);
}
const result = (target as Function).apply(this, args);
cache.set(key, result);
return result;
};
}
class FibCalculator {
@Memoize
fib(n: number): number {
if (n <= 1) return n;
return this.fib(n - 1) + this.fib(n - 2);
}
}
const fibCalc = new FibCalculator();
console.log(fibCalc.fib(10)); // 55
console.log(fibCalc.fib(10)); // [Memoize] Cache hit — 55
Property Decorators
A property decorator is attached above a property declaration and applies validation or transformation when the property is accessed.
// TypeScript 5.x property decorator
function NonNegative(
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: unknown, initialValue: number) {
let value = initialValue;
// Control the value through a getter/setter
Object.defineProperty(this, context.name, {
get() { return value; },
set(newValue: number) {
if (newValue < 0) {
throw new RangeError(
`${String(context.name)} cannot be negative. Received: ${newValue}`
);
}
value = newValue;
},
enumerable: true,
configurable: true,
});
return initialValue;
};
}
function MaxLength(max: number) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: unknown, initialValue: string) {
let value = initialValue.slice(0, max);
Object.defineProperty(this, context.name, {
get() { return value; },
set(newValue: string) {
value = String(newValue).slice(0, max);
},
enumerable: true,
configurable: true,
});
return value;
};
};
}
class Product {
@MaxLength(50)
name: string = "";
@NonNegative
price: number = 0;
@NonNegative
stock: number = 0;
}
const product = new Product();
product.name = "TypeScript Complete Mastery Course — Ultimate Edition 2025 (Unlimited Access)";
console.log(product.name.length); // truncated to 50 characters
product.price = 29.99;
// product.price = -100; // RangeError: price cannot be negative.
Parameter Decorators
A parameter decorator is placed before a method parameter and injects metadata onto it. It is used primarily in dependency-injection frameworks.
// Metadata symbol for storing required parameter indices
const REQUIRED_PARAMS = Symbol("required_params");
function Required(
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) {
const existingRequired: number[] =
Reflect.getOwnMetadata(REQUIRED_PARAMS, target, propertyKey) || [];
existingRequired.push(parameterIndex);
Reflect.defineMetadata(REQUIRED_PARAMS, existingRequired, target, propertyKey);
}
// Method decorator that validates required parameters
function ValidateParams(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: unknown[]) {
const requiredParams: number[] =
Reflect.getOwnMetadata(REQUIRED_PARAMS, target, propertyKey) || [];
requiredParams.forEach((paramIndex) => {
if (args[paramIndex] === undefined || args[paramIndex] === null) {
throw new Error(
`Parameter at index ${paramIndex} of ${propertyKey} is required.`
);
}
});
return originalMethod.apply(this, args);
};
}
class UserService {
@ValidateParams
createUser(@Required name: string, @Required email: string, age?: number): object {
return { name, email, age };
}
}
Decorator Factories — Decorators That Accept Arguments
For a decorator to accept arguments, you create a factory function that returns the decorator.
// Retry decorator factory
function Retry(times: number, delayMs: number = 0) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return async function (this: unknown, ...args: unknown[]) {
let lastError: unknown;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await (target as Function).apply(this, args);
} catch (err) {
lastError = err;
console.warn(
`[Retry] ${String(context.name)} failed (attempt ${attempt}/${times}):`,
(err as Error).message
);
if (attempt < times && delayMs > 0) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
throw lastError;
};
};
}
// Role-check decorator factory
function RequireRole(...roles: string[]) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return function (this: { currentRole?: string }, ...args: unknown[]) {
const userRole = this.currentRole ?? "guest";
if (!roles.includes(userRole)) {
throw new Error(
`Insufficient permissions: ${String(context.name)} requires [${roles.join(", ")}].`
);
}
return (target as Function).apply(this, args);
};
};
}
class ApiService {
currentRole: string = "admin";
@Retry(3, 500)
async fetchData(url: string): Promise<string> {
// Simulating a network error
if (Math.random() < 0.7) throw new Error("Network Error");
return `Data from ${url}`;
}
@RequireRole("admin", "superuser")
deleteUser(userId: number): void {
console.log(`User ${userId} deleted`);
}
@RequireRole("guest")
viewProfile(userId: number): void {
console.log(`Viewing profile of user ${userId}`);
}
}
const api = new ApiService();
api.deleteUser(42); // OK: currentRole is admin
api.currentRole = "user";
// api.deleteUser(42); // Error: insufficient permissions
Decorators in NestJS and TypeORM
Understanding how decorators are used inside real-world frameworks.
// NestJS style (experimentalDecorators-based)
// @Injectable: register with the DI container
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata("injectable", true, target);
};
}
// @Controller: register HTTP router
function Controller(path: string) {
return function (target: Function) {
Reflect.defineMetadata("path", path, target);
};
}
// @Get: register an HTTP GET handler
function Get(path: string = "") {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const routes = Reflect.getOwnMetadata("routes", target.constructor) || [];
routes.push({ method: "GET", path, handler: propertyKey });
Reflect.defineMetadata("routes", routes, target.constructor);
};
}
// TypeORM style
// @Entity: map to a database table
function Entity(tableName?: string) {
return function (target: Function) {
Reflect.defineMetadata("entity", { tableName: tableName ?? target.name }, target);
};
}
// @Column: define a column
function Column(options?: { type?: string; nullable?: boolean }) {
return function (target: any, propertyKey: string) {
const columns = Reflect.getOwnMetadata("columns", target.constructor) || [];
columns.push({ name: propertyKey, ...options });
Reflect.defineMetadata("columns", columns, target.constructor);
};
}
// @PrimaryGeneratedColumn: auto-increment primary key
function PrimaryGeneratedColumn() {
return function (target: any, propertyKey: string) {
Reflect.defineMetadata("primaryKey", propertyKey, target.constructor);
};
}
// Usage example (mirrors the real NestJS/TypeORM structure)
@Entity("users")
class UserEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: "varchar", nullable: false })
name!: string;
@Column({ type: "varchar", nullable: false })
email!: string;
}
Practical Example: @Log, @Memoize, @Validate, @Injectable
// @Validate: type-check method parameters
function Validate(schema: Record<string, "string" | "number" | "boolean">) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return function (this: unknown, params: Record<string, unknown>) {
for (const [key, type] of Object.entries(schema)) {
if (!(key in params)) {
throw new Error(`Missing required field: ${key}`);
}
if (typeof params[key] !== type) {
throw new TypeError(
`Wrong type for ${key}: expected ${type}, received ${typeof params[key]}`
);
}
}
return (target as Function).apply(this, [params]);
};
};
}
// @Debounce: suppress rapid successive calls
function Debounce(ms: number) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: unknown, ...args: unknown[]) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
(target as Function).apply(this, args);
timer = null;
}, ms);
};
};
}
// @Throttle: execute at most once per interval
function Throttle(ms: number) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
let lastCall = 0;
return function (this: unknown, ...args: unknown[]) {
const now = Date.now();
if (now - lastCall >= ms) {
lastCall = now;
return (target as Function).apply(this, args);
}
console.log(`[Throttle] ${String(context.name)} call suppressed`);
};
};
}
class SearchService {
@Debounce(300)
search(query: string): void {
console.log(`Searching: ${query}`);
}
@Throttle(1000)
trackEvent(event: string): void {
console.log(`Event tracked: ${event}`);
}
@Validate({ name: "string", age: "number" })
createProfile(params: { name: string; age: number }): void {
console.log(`Profile created: ${params.name}, age ${params.age}`);
}
}
const svc = new SearchService();
svc.createProfile({ name: "Alice", age: 30 }); // OK
// svc.createProfile({ name: "Bob", age: "30" }); // TypeError
Pro Tips
Decorator Execution Order
Understanding execution order matters when multiple decorators are applied to a class.
function First() {
console.log("First factory evaluated");
return function (target: Function, context: ClassDecoratorContext) {
console.log("First decorator executed");
};
}
function Second() {
console.log("Second factory evaluated");
return function (target: Function, context: ClassDecoratorContext) {
console.log("Second decorator executed");
};
}
@First()
@Second()
class Example {}
// Output order:
// First factory evaluated (evaluated top to bottom)
// Second factory evaluated
// Second decorator executed (executed bottom to top)
// First decorator executed
Method decorators run before property decorators, and class decorators run last.
reflect-metadata
Using the reflect-metadata package lets you store type information as metadata and query it at runtime. Use it together with the emitDecoratorMetadata: true option.
import "reflect-metadata";
function Inject() {
return function (target: any, propertyKey: string) {
const type = Reflect.getMetadata("design:type", target, propertyKey);
console.log(`Type of ${propertyKey}: ${type.name}`);
};
}
class Service {
doWork(): void { console.log("Doing work"); }
}
class Controller {
@Inject()
service!: Service; // metadata: design:type = Service
}
// Output: Type of service: Service
// A DI container uses this information to inject a Service instance automatically
Summary
| Decorator Kind | Applied To | Primary Uses |
|---|---|---|
| Class decorator | Class declaration | Metadata injection, class transformation, singleton |
| Method decorator | Method declaration | Logging, caching, authorization, retry |
| Property decorator | Property declaration | Validation, serialization, transformation |
| Parameter decorator | Parameter | Dependency injection, required parameter checks |
| Decorator factory | All locations | Dynamically generate decorators that accept arguments |
| Approach | Option | Use Case |
|---|---|---|
| Standard (TC39 Stage 3) | Built-in support (TS 5.0+) | Recommended for new projects |
| Legacy | experimentalDecorators: true | NestJS, Angular, TypeORM |
In the next chapter we learn how to apply the SOLID principles in TypeScript. We compare code that violates each principle against properly refactored code, and integrate all five principles in a practical order-processing system.