|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/decorators
$cat docs/typescript-—-decorators.md
updated Last week·18 min read·published

TypeScript — Decorators

TypeScriptAdvanced🎯Free Tools
Introduction

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.

Configuration
tsconfig.json
JSON
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "Node16",
5 "experimentalDecorators": true,
6 "emitDecoratorMetadata": true,
7 "strict": true
8 }
9}

info

emitDecoratorMetadata enables automatic metadata emission for parameter type information. This is required by libraries like class-validator and NestJS.
Class Decorators

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.

class_decorators.ts
TypeScript
1// Simple class decorator — receives the constructor
2function Sealed(constructor: Function) {
3 Object.seal(constructor);
4 Object.seal(constructor.prototype);
5}
6
7// Usage
8@Sealed
9class 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
21function 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
31class 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
39function 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
54class DatabaseConnection {
55 constructor() {
56 console.log("Database connection established");
57 }
58}
59
60const a = new DatabaseConnection(); // "Database connection established"
61const b = new DatabaseConnection(); // No output — reuses instance
62console.log(a === b); // true
Method Decorators
method_decorators.ts
TypeScript
1// Method decorator — receives (target, key, descriptor)
2function 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
20function 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
41function 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
64class 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

When using emitDecoratorMetadata, method decorators can also access reflected parameter types via Reflect.getMetadata.
Property Decorators
property_decorators.ts
TypeScript
1// Property decorator — receives (target, key)
2// Cannot access descriptor (properties don't have descriptors on the instance)
3function 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
23function 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
31function 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
44class UserForm {
45 @Validate
46 name!: string;
47
48 @Trim
49 bio!: string;
50
51 @Readonly
52 id: string = "user_123";
53}
54
55const form = new UserForm();
56form.name = "Alice"; // OK
57form.name = 123 as any; // TypeError: name must be a string
58form.bio = " hello "; // Stored as "hello"
59// form.id = "new_id"; // Error: id is readonly
Parameter Decorators
parameter_decorators.ts
TypeScript
1// Parameter decorator — receives (target, key, index)
2// Used primarily for framework-level DI (NestJS, InversifyJS)
3function 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
9function 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
15function 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
22function 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
29class 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

Parameter decorators alone do not modify behavior. They store metadata that is consumed by method or class decorators to implement dependency injection or validation.
Decorator Factories

A decorator factory is a function that returns a decorator. This enables parameterized decorators — the most common pattern in production code.

decorator_factories.ts
TypeScript
1// Factory that returns a class decorator with options
2interface CacheOptions {
3 ttl: number; // time-to-live in seconds
4 key?: string; // custom cache key
5}
6
7function 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
33function 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
58function 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}
Decorator Composition
composition.ts
TypeScript
1// Multiple decorators are applied bottom-up (innermost first)
2// @A @B class C → A(B(C))
3
4function First() {
5 return function (target: any) {
6 console.log("First applied");
7 return target;
8 };
9}
10
11function Second() {
12 return function (target: any) {
13 console.log("Second applied");
14 return target;
15 };
16}
17
18@First()
19@Second()
20class MyClass {}
21// Output: "Second applied" then "First applied"
22
23// Composition utility for combining decorators
24function 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
39const Auditable = compose(Log, Retry(3), Debounce(300));
40
41class Service {
42 @Auditable
43 async process(data: string) {
44 return data.toUpperCase();
45 }
46}

info

Decorators execute bottom-to-top when multiple are stacked. Think of @A @B as wrapping: B wraps the target first, then A wraps the result of B.
Stage 3 Decorators

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.

stage3_decorators.ts
TypeScript
1// Stage 3 decorator — uses context object instead of descriptors
2function 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
14function 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
26function 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
44function 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
64class 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}
Real-World Patterns
real_world.ts
TypeScript
1// Memoization decorator
2function 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
24function 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
42function 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
62class 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

Decorators excel at cross-cutting concerns: logging, validation, caching, authorization, and retry logic. Keep decorator logic focused and composable rather than creating monolithic decorators that do too much.
$Blueprint — Engineering Documentation·Section ID: TS-DECOR·Revision: 1.0