TypeScript Decorators — Metaprogramming Patterns for Backend APIs
Advertisement
Introduction
Why This Matters
Decorators are TypeScript's metaprogramming primitive. They let you annotate and modify classes, methods, properties, and parameters at definition time — enabling declarative patterns that would otherwise require repetitive boilerplate. NestJS uses decorators for routing, dependency injection, validation, and guards. TypeORM uses them for entity mapping. class-validator uses them for declarative input validation.
Understanding decorators from first principles gives you power beyond just using framework APIs. You can build your own route handlers, caching layers, retry logic, and authorization guards — all expressed as clean, composable annotations. As TypeScript's Stage 3 decorator standard matures, decorators are becoming more reliable and standardized across toolchains.
Backend engineers who understand decorators write less repetitive code. Instead of wrapping every service method in error handling or logging, you apply a decorator once and the behavior propagates automatically.
Enabling Decorators
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}The Stage 3 proposal (TC39) is natively supported in newer TypeScript without the experimentalDecorators flag — but most production frameworks still use the legacy decorator spec.
Class Decorators
A class decorator receives the constructor function and can return a modified class.
// Freeze a class to prevent prototype modification
function Frozen<T extends { new (...args: any[]): object }>(constructor: T): T {
Object.freeze(constructor);
Object.freeze(constructor.prototype);
return constructor;
}
// Add metadata to a class
function Controller(prefix: string) {
return function <T extends { new (...args: any[]): object }>(constructor: T) {
Reflect.defineMetadata('prefix', prefix, constructor);
return constructor;
};
}
@Frozen
@Controller('/users')
class UserController {
// Methods here
}
const prefix = Reflect.getMetadata('prefix', UserController); // '/users'Method Decorators
Method decorators are the most commonly used. They receive the target, method name, and property descriptor.
// Retry decorator
function Retry(attempts: number, delayMs: number = 1000) {
return function (
_target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const original = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = async function (...args: unknown[]) {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await original.apply(this, args);
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
}
}
}
throw lastError;
};
return descriptor;
};
}
// Timing / performance decorator
function Timed(
_target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const original = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = async function (...args: unknown[]) {
const start = performance.now();
try {
return await original.apply(this, args);
} finally {
const duration = (performance.now() - start).toFixed(2);
console.log(`[${propertyKey}] took ${duration}ms`);
}
};
return descriptor;
}
class ExternalApiClient {
@Retry(3, 500)
@Timed
async fetchData(endpoint: string): Promise<unknown> {
const res = await fetch(endpoint);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}Property Decorators
Property decorators run at class definition time. They receive the prototype and property key.
// Validation decorator for class properties
function MinLength(min: number) {
return function (target: object, propertyKey: string): void {
const validators: Array<{ key: string; validate: (val: unknown) => string | null }> =
Reflect.getOwnMetadata('validators', target.constructor) ?? [];
validators.push({
key: propertyKey,
validate: (val) =>
typeof val === 'string' && val.length >= min
? null
: `${propertyKey} must be at least ${min} characters`,
});
Reflect.defineMetadata('validators', validators, target.constructor);
};
}
function validate(instance: object): string[] {
const validators: Array<{ key: string; validate: (val: unknown) => string | null }> =
Reflect.getOwnMetadata('validators', instance.constructor) ?? [];
const errors: string[] = [];
for (const { key, validate } of validators) {
const value = (instance as Record<string, unknown>)[key];
const error = validate(value);
if (error) errors.push(error);
}
return errors;
}
class CreateUserDto {
@MinLength(2)
name: string = '';
@MinLength(5)
email: string = '';
}
const dto = new CreateUserDto();
dto.name = 'A';
console.log(validate(dto)); // ['name must be at least 2 characters']Parameter Decorators
Parameter decorators annotate constructor or method parameters — commonly used for dependency injection.
const INJECT_METADATA_KEY = 'inject:tokens';
function Inject(token: string) {
return function (
target: object,
_propertyKey: string | symbol | undefined,
parameterIndex: number
): void {
const tokens: Record<number, string> =
Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? {};
tokens[parameterIndex] = token;
Reflect.defineMetadata(INJECT_METADATA_KEY, tokens, target);
};
}Decorator Composition and Ordering
Decorators are evaluated bottom-to-top (closest to the method runs first).
function Log(name: string) {
return function (_t: object, _k: string, descriptor: PropertyDescriptor) {
const original = descriptor.value as (...args: unknown[]) => unknown;
descriptor.value = function (...args: unknown[]) {
console.log(`[${name}] called`);
const result = original.apply(this, args);
console.log(`[${name}] done`);
return result;
};
return descriptor;
};
}
class Service {
@Log('outer') // evaluated second
@Log('inner') // evaluated first
doWork(): void {
console.log('working');
}
}
// Output: [inner] called → [outer] called → working → [outer] done → [inner] doneReal-World: Route Registration Pattern
const routes: Array<{ method: string; path: string; handler: string }> = [];
function Get(path: string) {
return function (_target: object, propertyKey: string, descriptor: PropertyDescriptor) {
routes.push({ method: 'GET', path, handler: propertyKey });
return descriptor;
};
}
function Post(path: string) {
return function (_target: object, propertyKey: string, descriptor: PropertyDescriptor) {
routes.push({ method: 'POST', path, handler: propertyKey });
return descriptor;
};
}
class UserController {
@Get('/users')
listUsers() { return []; }
@Post('/users')
createUser() { return {}; }
@Get('/users/:id')
getUser() { return {}; }
}
console.log(routes);
// [{ method: 'GET', path: '/users', handler: 'listUsers' }, ...]Common Mistakes
- Using decorators on plain functions — decorators only work on class members
- Forgetting to return the
descriptorfrom method decorators - Relying on
emitDecoratorMetadatafor runtime type info in environments where it is stripped - Applying mutable state inside decorator factories without proper closures
- Stacking too many decorators making execution order impossible to reason about
Best Practices
- Keep decorator factories pure — no side effects beyond metadata registration
- Document the execution order when stacking multiple decorators on a method
- Use
Reflect.getOwnMetadatato avoid inheriting parent class metadata accidentally - Prefer method decorators over monkey-patching prototypes manually
- Test decorated classes in isolation — verify both the decorator behavior and the original method
Key Takeaways
- Class decorators receive the constructor; they can return a new class to replace the original
- Method decorators wrap the original function via the
PropertyDescriptor— the standard extension point - Property decorators run at class definition time and commonly write Reflect metadata
- Parameter decorators record injection tokens or validation rules by parameter index
- Decorators execute bottom-to-top when multiple decorators are stacked on a single target
experimentalDecorators: trueuses the legacy spec; TC39 Stage 3 is the future standard- NestJS, TypeORM, and class-validator are built on top of TypeScript decorator metadata
- Decorators are evaluated once at class load — they add zero per-call overhead after initialization
Advertisement