TypeScript — Decorators
Decorators are a powerful way to add metadata, modify behavior, and compose reusable logic in TypeScript. They attach annotations or behavior to classes, methods, properties, and parameters without altering their core implementation. TypeScript has shipped experimental decorators for years while the TC39 Stage 3 decorator proposal moves toward standardization.
To use decorators today, enable experimentalDecorators in your tsconfig. The newer Stage 3 proposal offers a different API surface but the core concept remains the same: decorators are functions that receive a target and optionally a descriptor or context and return a modified or replacement value.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "target": "ES2022", |
| 4 | "module": "Node16", |
| 5 | "experimentalDecorators": true, |
| 6 | "emitDecoratorMetadata": true, |
| 7 | "strict": true |
| 8 | } |
| 9 | } |
info
A class decorator receives the class constructor and can modify it, replace it, or return a new constructor. This pattern is commonly used for adding metadata, implementing singletons, or registering classes with a DI container.
| 1 | // Simple class decorator — receives the constructor |
| 2 | function Sealed(constructor: Function) { |
| 3 | Object.seal(constructor); |
| 4 | Object.seal(constructor.prototype); |
| 5 | } |
| 6 | |
| 7 | // Usage |
| 8 | @Sealed |
| 9 | class Greeter { |
| 10 | greeting: string; |
| 11 | constructor(message: string) { |
| 12 | this.greeting = message; |
| 13 | } |
| 14 | greet() { |
| 15 | return "Hello, " + this.greeting; |
| 16 | } |
| 17 | } |
| 18 | // Cannot extend Greeter — it is sealed |
| 19 | |
| 20 | // Class decorator that modifies the constructor |
| 21 | function Logging<T extends new (...args: any[]) => {}>(constructor: T) { |
| 22 | return class extends constructor { |
| 23 | constructor(...args: any[]) { |
| 24 | super(...args); |
| 25 | console.log(`[${constructor.name}] instantiated with args:`, args); |
| 26 | } |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | @Logging |
| 31 | class UserService { |
| 32 | constructor(private db: string, private cache: boolean) {} |
| 33 | } |
| 34 | |
| 35 | // Output when new UserService("postgres", true): |
| 36 | // [UserService] instantiated with args: ["postgres", true] |
| 37 | |
| 38 | // Singleton pattern via class decorator |
| 39 | function Singleton<T extends new (...args: any[]) => any>(constructor: T) { |
| 40 | let instance: T | null = null; |
| 41 | |
| 42 | return class extends T { |
| 43 | constructor(...args: any[]) { |
| 44 | if (instance) { |
| 45 | return instance; |
| 46 | } |
| 47 | super(...args); |
| 48 | instance = this as unknown as T; |
| 49 | } |
| 50 | } as unknown as T; |
| 51 | } |
| 52 | |
| 53 | @Singleton |
| 54 | class DatabaseConnection { |
| 55 | constructor() { |
| 56 | console.log("Database connection established"); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | const a = new DatabaseConnection(); // "Database connection established" |
| 61 | const b = new DatabaseConnection(); // No output — reuses instance |
| 62 | console.log(a === b); // true |
| 1 | // Method decorator — receives (target, key, descriptor) |
| 2 | function Log( |
| 3 | target: any, |
| 4 | key: string, |
| 5 | descriptor: PropertyDescriptor |
| 6 | ) { |
| 7 | const original = descriptor.value; |
| 8 | |
| 9 | descriptor.value = function (...args: any[]) { |
| 10 | console.log(`Calling ${key} with`, args); |
| 11 | const result = original.apply(this, args); |
| 12 | console.log(`${key} returned`, result); |
| 13 | return result; |
| 14 | }; |
| 15 | |
| 16 | return descriptor; |
| 17 | } |
| 18 | |
| 19 | // Debounce decorator |
| 20 | function Debounce(delay: number) { |
| 21 | return function ( |
| 22 | target: any, |
| 23 | key: string, |
| 24 | descriptor: PropertyDescriptor |
| 25 | ) { |
| 26 | let timeoutId: NodeJS.Timeout; |
| 27 | const original = descriptor.value; |
| 28 | |
| 29 | descriptor.value = function (...args: any[]) { |
| 30 | clearTimeout(timeoutId); |
| 31 | timeoutId = setTimeout(() => { |
| 32 | original.apply(this, args); |
| 33 | }, delay); |
| 34 | }; |
| 35 | |
| 36 | return descriptor; |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | // Retry decorator for async methods |
| 41 | function Retry(attempts: number) { |
| 42 | return function ( |
| 43 | target: any, |
| 44 | key: string, |
| 45 | descriptor: PropertyDescriptor |
| 46 | ) { |
| 47 | const original = descriptor.value; |
| 48 | |
| 49 | descriptor.value = async function (...args: any[]) { |
| 50 | for (let i = 0; i < attempts; i++) { |
| 51 | try { |
| 52 | return await original.apply(this, args); |
| 53 | } catch (error) { |
| 54 | if (i === attempts - 1) throw error; |
| 55 | console.log(`Attempt ${i + 1} failed, retrying...`); |
| 56 | } |
| 57 | } |
| 58 | }; |
| 59 | |
| 60 | return descriptor; |
| 61 | }; |
| 62 | } |
| 63 | |
| 64 | class ApiClient { |
| 65 | @Log |
| 66 | getUser(id: string) { |
| 67 | return { id, name: "Alice" }; |
| 68 | } |
| 69 | |
| 70 | @Debounce(300) |
| 71 | search(query: string) { |
| 72 | console.log("Searching:", query); |
| 73 | } |
| 74 | |
| 75 | @Retry(3) |
| 76 | async fetchData(url: string) { |
| 77 | const response = await fetch(url); |
| 78 | return response.json(); |
| 79 | } |
| 80 | } |
info
| 1 | // Property decorator — receives (target, key) |
| 2 | // Cannot access descriptor (properties don't have descriptors on the instance) |
| 3 | function Validate(target: any, key: string) { |
| 4 | let value: any; |
| 5 | |
| 6 | const getter = () => value; |
| 7 | const setter = (newVal: any) => { |
| 8 | if (typeof newVal !== "string") { |
| 9 | throw new TypeError(`${key} must be a string`); |
| 10 | } |
| 11 | value = newVal; |
| 12 | }; |
| 13 | |
| 14 | Object.defineProperty(target, key, { |
| 15 | get: getter, |
| 16 | set: setter, |
| 17 | enumerable: true, |
| 18 | configurable: true, |
| 19 | }); |
| 20 | } |
| 21 | |
| 22 | // Readonly decorator — prevents reassignment |
| 23 | function Readonly(target: any, key: string) { |
| 24 | Object.defineProperty(target, key, { |
| 25 | writable: false, |
| 26 | configurable: false, |
| 27 | }); |
| 28 | } |
| 29 | |
| 30 | // Transform decorator — auto-transforms values on set |
| 31 | function Trim(target: any, key: string) { |
| 32 | let value: string; |
| 33 | |
| 34 | Object.defineProperty(target, key, { |
| 35 | get: () => value, |
| 36 | set: (newVal: string) => { |
| 37 | value = typeof newVal === "string" ? newVal.trim() : newVal; |
| 38 | }, |
| 39 | enumerable: true, |
| 40 | configurable: true, |
| 41 | }); |
| 42 | } |
| 43 | |
| 44 | class UserForm { |
| 45 | @Validate |
| 46 | name!: string; |
| 47 | |
| 48 | @Trim |
| 49 | bio!: string; |
| 50 | |
| 51 | @Readonly |
| 52 | id: string = "user_123"; |
| 53 | } |
| 54 | |
| 55 | const form = new UserForm(); |
| 56 | form.name = "Alice"; // OK |
| 57 | form.name = 123 as any; // TypeError: name must be a string |
| 58 | form.bio = " hello "; // Stored as "hello" |
| 59 | // form.id = "new_id"; // Error: id is readonly |
| 1 | // Parameter decorator — receives (target, key, index) |
| 2 | // Used primarily for framework-level DI (NestJS, InversifyJS) |
| 3 | function Body(target: any, key: string, index: number) { |
| 4 | const existingMetadata = Reflect.getMetadata("body", target, key) || []; |
| 5 | existingMetadata.push(index); |
| 6 | Reflect.defineMetadata("body", existingMetadata, target, key); |
| 7 | } |
| 8 | |
| 9 | function Query(target: any, key: string, index: number) { |
| 10 | const existingMetadata = Reflect.getMetadata("query", target, key) || []; |
| 11 | existingMetadata.push(index); |
| 12 | Reflect.defineMetadata("query", existingMetadata, target, key); |
| 13 | } |
| 14 | |
| 15 | function Param(target: any, key: string, index: number) { |
| 16 | const existingMetadata = Reflect.getMetadata("param", target, key) || []; |
| 17 | existingMetadata.push(index); |
| 18 | Reflect.defineMetadata("param", existingMetadata, target, key); |
| 19 | } |
| 20 | |
| 21 | // Combined with method decorators for a mini-framework |
| 22 | function Route(method: string, path: string) { |
| 23 | return function (target: any, key: string, descriptor: PropertyDescriptor) { |
| 24 | Reflect.defineMetadata("method", method, target, key); |
| 25 | Reflect.defineMetadata("path", path, target, key); |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | class UserController { |
| 30 | @Route("POST", "/users") |
| 31 | create(@Body body: any) { |
| 32 | return { status: "created", data: body }; |
| 33 | } |
| 34 | |
| 35 | @Route("GET", "/users/:id") |
| 36 | findOne(@Param id: string, @Query query: any) { |
| 37 | return { id, query }; |
| 38 | } |
| 39 | } |
warning
A decorator factory is a function that returns a decorator. This enables parameterized decorators — the most common pattern in production code.
| 1 | // Factory that returns a class decorator with options |
| 2 | interface CacheOptions { |
| 3 | ttl: number; // time-to-live in seconds |
| 4 | key?: string; // custom cache key |
| 5 | } |
| 6 | |
| 7 | function Cacheable(options: CacheOptions) { |
| 8 | return function <T extends new (...args: any[]) => any>(constructor: T) { |
| 9 | return class extends constructor { |
| 10 | private cache = new Map<string, { data: any; expiry: number }>(); |
| 11 | |
| 12 | getCached(key: string): any | undefined { |
| 13 | const entry = this.cache.get(key); |
| 14 | if (!entry) return undefined; |
| 15 | if (Date.now() > entry.expiry) { |
| 16 | this.cache.delete(key); |
| 17 | return undefined; |
| 18 | } |
| 19 | return entry.data; |
| 20 | } |
| 21 | |
| 22 | setCache(key: string, data: any): void { |
| 23 | this.cache.set(key, { |
| 24 | data, |
| 25 | expiry: Date.now() + options.ttl * 1000, |
| 26 | }); |
| 27 | } |
| 28 | }; |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | // Factory for validation |
| 33 | function ValidateSchema(schema: { required?: string[]; optional?: string[] }) { |
| 34 | return function ( |
| 35 | target: any, |
| 36 | key: string, |
| 37 | descriptor: PropertyDescriptor |
| 38 | ) { |
| 39 | const original = descriptor.value; |
| 40 | |
| 41 | descriptor.value = function (...args: any[]) { |
| 42 | const data = args[0]; |
| 43 | if (schema.required) { |
| 44 | for (const field of schema.required) { |
| 45 | if (!(field in data)) { |
| 46 | throw new Error(`Missing required field: ${field}`); |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | return original.apply(this, args); |
| 51 | }; |
| 52 | |
| 53 | return descriptor; |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | // Factory for rate limiting |
| 58 | function RateLimit(maxCalls: number, windowMs: number) { |
| 59 | return function ( |
| 60 | target: any, |
| 61 | key: string, |
| 62 | descriptor: PropertyDescriptor |
| 63 | ) { |
| 64 | const calls: number[] = []; |
| 65 | const original = descriptor.value; |
| 66 | |
| 67 | descriptor.value = function (...args: any[]) { |
| 68 | const now = Date.now(); |
| 69 | const recentCalls = calls.filter((t) => now - t < windowMs); |
| 70 | calls.push(...recentCalls.length >= maxCalls ? [] : [now]); |
| 71 | |
| 72 | if (recentCalls.length >= maxCalls) { |
| 73 | throw new Error(`Rate limit exceeded for ${key}`); |
| 74 | } |
| 75 | |
| 76 | return original.apply(this, args); |
| 77 | }; |
| 78 | |
| 79 | return descriptor; |
| 80 | }; |
| 81 | } |
| 1 | // Multiple decorators are applied bottom-up (innermost first) |
| 2 | // @A @B class C → A(B(C)) |
| 3 | |
| 4 | function First() { |
| 5 | return function (target: any) { |
| 6 | console.log("First applied"); |
| 7 | return target; |
| 8 | }; |
| 9 | } |
| 10 | |
| 11 | function Second() { |
| 12 | return function (target: any) { |
| 13 | console.log("Second applied"); |
| 14 | return target; |
| 15 | }; |
| 16 | } |
| 17 | |
| 18 | @First() |
| 19 | @Second() |
| 20 | class MyClass {} |
| 21 | // Output: "Second applied" then "First applied" |
| 22 | |
| 23 | // Composition utility for combining decorators |
| 24 | function compose(...decorators: Function[]) { |
| 25 | return function (target: any, key?: string, descriptor?: PropertyDescriptor) { |
| 26 | if (key !== undefined && descriptor !== undefined) { |
| 27 | // Method or property decorator |
| 28 | return decorators.reduceRight( |
| 29 | (acc, dec) => dec(target, key, acc), |
| 30 | descriptor |
| 31 | ); |
| 32 | } |
| 33 | // Class decorator |
| 34 | return decorators.reduceRight((acc, dec) => dec(acc), target); |
| 35 | }; |
| 36 | } |
| 37 | |
| 38 | // Composing multiple method decorators |
| 39 | const Auditable = compose(Log, Retry(3), Debounce(300)); |
| 40 | |
| 41 | class Service { |
| 42 | @Auditable |
| 43 | async process(data: string) { |
| 44 | return data.toUpperCase(); |
| 45 | } |
| 46 | } |
info
The TC39 Stage 3 decorator proposal introduces a different API. Instead of descriptor-based methods, decorators receive a context object with kind, name, access, and other metadata. This proposal is expected to ship in future TypeScript versions.
| 1 | // Stage 3 decorator — uses context object instead of descriptors |
| 2 | function Logged(value: any, context: ClassMethodDecoratorContext) { |
| 3 | const methodName = String(context.name); |
| 4 | |
| 5 | return function (this: any, ...args: any[]) { |
| 6 | console.log(`[${methodName}] called with`, args); |
| 7 | const result = value.call(this, ...args); |
| 8 | console.log(`[${methodName}] returned`, result); |
| 9 | return result; |
| 10 | }; |
| 11 | } |
| 12 | |
| 13 | // Stage 3 class decorator — receives class directly |
| 14 | function Override<T extends new (...args: any[]) => any>( |
| 15 | value: T, |
| 16 | context: ClassDecoratorContext |
| 17 | ) { |
| 18 | return class extends value { |
| 19 | toString() { |
| 20 | return `[Override: ${context.name}]`; |
| 21 | } |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | // Stage 3 auto-bind decorator |
| 26 | function AutoBind( |
| 27 | value: any, |
| 28 | context: ClassMethodDecoratorContext |
| 29 | ) { |
| 30 | let boundMethod: typeof value; |
| 31 | const kind = context.kind; |
| 32 | |
| 33 | if (kind === "method") { |
| 34 | return function (this: any, ...args: any[]) { |
| 35 | if (!boundMethod) { |
| 36 | boundMethod = value.bind(this); |
| 37 | } |
| 38 | return boundMethod(...args); |
| 39 | }; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // Stage 3 field decorator with access |
| 44 | function CountCalls( |
| 45 | value: undefined, |
| 46 | context: ClassFieldDecoratorContext |
| 47 | ) { |
| 48 | const field = String(context.name); |
| 49 | |
| 50 | return function (this: any, initialValue: any) { |
| 51 | return { |
| 52 | get() { |
| 53 | const val = this[field]; |
| 54 | return val === undefined ? 0 : val; |
| 55 | }, |
| 56 | set(val: number) { |
| 57 | this[field] = val; |
| 58 | console.log(`${field} set to ${val}`); |
| 59 | }, |
| 60 | }; |
| 61 | }; |
| 62 | } |
| 63 | |
| 64 | class Calculator { |
| 65 | @CountCalls |
| 66 | total: number = 0; |
| 67 | |
| 68 | @Logged |
| 69 | add(a: number, b: number) { |
| 70 | this.total = a + b; |
| 71 | return this.total; |
| 72 | } |
| 73 | } |
| 1 | // Memoization decorator |
| 2 | function Memoize( |
| 3 | target: any, |
| 4 | key: string, |
| 5 | descriptor: PropertyDescriptor |
| 6 | ) { |
| 7 | const original = descriptor.value; |
| 8 | const memo = new Map<string, any>(); |
| 9 | |
| 10 | descriptor.value = function (...args: any[]) { |
| 11 | const keyStr = JSON.stringify(args); |
| 12 | if (memo.has(keyStr)) { |
| 13 | return memo.get(keyStr); |
| 14 | } |
| 15 | const result = original.apply(this, args); |
| 16 | memo.set(keyStr, result); |
| 17 | return result; |
| 18 | }; |
| 19 | |
| 20 | return descriptor; |
| 21 | } |
| 22 | |
| 23 | // Event emitter pattern |
| 24 | function OnEvent(eventName: string) { |
| 25 | return function ( |
| 26 | target: any, |
| 27 | key: string, |
| 28 | descriptor: PropertyDescriptor |
| 29 | ) { |
| 30 | if (!target._eventHandlers) { |
| 31 | target._eventHandlers = {}; |
| 32 | } |
| 33 | if (!target._eventHandlers[eventName]) { |
| 34 | target._eventHandlers[eventName] = []; |
| 35 | } |
| 36 | target._eventHandlers[eventName].push(descriptor.value); |
| 37 | return descriptor; |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | // Authorization decorator |
| 42 | function Authorize(...roles: string[]) { |
| 43 | return function ( |
| 44 | target: any, |
| 45 | key: string, |
| 46 | descriptor: PropertyDescriptor |
| 47 | ) { |
| 48 | const original = descriptor.value; |
| 49 | |
| 50 | descriptor.value = function (this: any, ...args: any[]) { |
| 51 | const user = this.currentUser; |
| 52 | if (!user || !roles.includes(user.role)) { |
| 53 | throw new Error(`Unauthorized: requires role ${roles.join(" or ")}`); |
| 54 | } |
| 55 | return original.apply(this, args); |
| 56 | }; |
| 57 | |
| 58 | return descriptor; |
| 59 | }; |
| 60 | } |
| 61 | |
| 62 | class AdminController { |
| 63 | currentUser = { name: "Alice", role: "admin" }; |
| 64 | |
| 65 | @Authorize("admin") |
| 66 | deleteUser(id: string) { |
| 67 | console.log(`User ${id} deleted`); |
| 68 | } |
| 69 | |
| 70 | @Authorize("admin", "moderator") |
| 71 | moderateContent(postId: string) { |
| 72 | console.log(`Post ${postId} moderated`); |
| 73 | } |
| 74 | } |
best practice