Deep-dive into TypeScript decorators — class, method, property, accessor, and parameter decorators. Understand the execution order, decorator factories, metadata reflection, and how frameworks like NestJS, Angular, and MobX are built on top of this feature.
Decorators are annotations that wrap or enhance a class, method, property, or parameter at decoration time — similar to Python decorators or Java annotations, but with the ability to modify the target. Think of them as gift-wrapping functions: you pass in the original class or method, you add your enhancement layer around it (logging, validation, caching, dependency injection), and the consumer interacts with the enhanced version without knowing the original is wrapped inside. The critical thing to understand is that decorators run at class definition time (when the module loads), not at instance creation time — this determines what they can and cannot do.
Decorators are currently a Stage 3 TC39 proposal. TypeScript supports both the legacy experimental decorators (widely used, requires experimentalDecorators: true) and the newer Stage 3 decorators. Most production frameworks (NestJS, Angular, MobX) use the legacy form. This guide covers the legacy form since it dominates interview contexts:
// tsconfig.json — required for legacy decorators
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true // required for reflect-metadata
}
}
// A decorator is a function — it receives the decorated target as its argument
function Log(target: Function) {
console.log(`Class defined: ${target.name}`);
}
@Log
class UserService {
// Log runs at class definition time, once, when this module loads
// NOT when new UserService() is called
}
// Class decorator — receives the constructor function
// Can return a new constructor to replace the original class
type Constructor<T = {}> = new (...args: any[]) => T;
function Singleton<T extends Constructor>(Base: T) {
let instance: InstanceType<T> | null = null;
return class extends Base {
constructor(...args: any[]) {
if (instance) return instance as any;
super(...args);
instance = this as any;
}
};
}
@Singleton
class DatabaseConnection {
connect() { /* ... */ }
}
const a = new DatabaseConnection();
const b = new DatabaseConnection();
console.log(a === b); // true — same instance
// Method decorator receives: target (prototype), key (method name), descriptor
function Memoize(
target: any,
key: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const original = descriptor.value;
const cache = new Map<string, any>();
descriptor.value = function(...args: any[]) {
const cacheKey = JSON.stringify(args);
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const result = original.apply(this, args);
cache.set(cacheKey, result);
return result;
};
return descriptor;
}
class MathService {
@Memoize
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
// Logging decorator — a real-world pattern
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;
};
return descriptor;
}
A decorator factory is a function that returns a decorator — allowing the decorator to be parameterized:
// Factory — outer function takes config, returns the actual decorator
function Throttle(ms: number) {
return function(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
let lastCall = 0;
descriptor.value = function(...args: any[]) {
const now = Date.now();
if (now - lastCall >= ms) {
lastCall = now;
return original.apply(this, args);
}
};
return descriptor;
};
}
class SearchService {
@Throttle(300) // Decorator factory — passes 300 to the outer function
search(query: string) { /* ... */ }
}
// NestJS uses decorator factories extensively:
// @Controller('users')
// @Get(':id')
// @Body(), @Param('id'), @Injectable()
// All of these are decorator factories returning the actual decorator
// Property decorator — receives target and property key
// Cannot directly modify the property (no descriptor provided)
// Typically used with reflect-metadata to record metadata
function Required(target: any, key: string) {
const metadata: string[] = Reflect.getMetadata("required", target) || [];
metadata.push(key);
Reflect.defineMetadata("required", metadata, target);
}
// Parameter decorator — receives target, method name, and parameter index
function ValidateId(target: any, key: string, index: number) {
// Marks the parameter at 'index' in method 'key' for validation
const params: number[] = Reflect.getMetadata("validateId", target, key) || [];
params.push(index);
Reflect.defineMetadata("validateId", params, target, key);
}
class UserController {
@Required
name: string = "";
getUser(@ValidateId id: string) {
return users.find(u => u.id === id);
}
}
// 1. Parameter decorators evaluated bottom-up, then
// 2. Method/accessor/property decorators evaluated bottom-up, then
// 3. Parameter decorators for constructor, then
// 4. Class decorator evaluated last
@ClassDec // Runs 4th
class Example {
@PropDec // Runs 3rd
value: string;
@MethodDec1 // Runs 2nd (outer)
@MethodDec2 // Runs 1st (inner — applied closer to method first)
method(@ParamDec param: string) {}
}
// The general rule: innermost decorator runs first, class decorator runs lastMany developers think decorators run when instances are created — class and method decorators run at class definition time, when the module loads. They are applied once to the prototype or constructor, not once per instance. Only the wrapper function they install runs per call.
Many developers think TypeScript decorators are stable and finalized — the legacy experimental decorators (experimentalDecorators: true) have been in TypeScript since v1.5 but are NOT the TC39 standard. The actual Stage 3 decorator proposal has a different API. Most frameworks still use the legacy form.
Many developers think property decorators can intercept gets and sets directly — unlike method decorators, property decorators do not receive a PropertyDescriptor. To observe property access, you need to use Object.defineProperty inside the decorator or combine with reflect-metadata.
Many developers think decorators are just syntactic sugar for higher-order functions applied manually — they are structurally equivalent, but they are applied at a specific phase in class evaluation, which determines the execution order and what targets are available.
Many developers think emitDecoratorMetadata is optional — it is required to use reflect-metadata for dependency injection (the technique Angular and NestJS rely on to resolve constructor parameter types). Without it, Reflect.getMetadata('design:type') returns undefined.
Many developers think decorators are only useful in NestJS and Angular — MobX uses decorators for observable and computed, TypeORM for Entity and Column, class-validator for validation rules, and Swagger for API documentation — all common across the TypeScript ecosystem.
NestJS dependency injection: @Injectable(), @Controller('users'), @Get(':id'), and @Body() are all decorators. The framework reads decorator metadata at startup to wire up the dependency injection container, route handlers, and request parsing automatically.
Angular components: @Component({ selector, template, styles }) is a class decorator factory that registers the component with Angular's compiler and attaches its metadata — template, styles, change detection strategy — to the class.
MobX state management: @observable and @computed are property decorators that replace the property with a getter/setter pair backed by MobX's reactive system — every assignment triggers reactive updates without any manual subscription code.
class-validator: @IsEmail(), @MinLength(8), @IsOptional() are property decorators that store validation rules as metadata. The validate(instance) function reads that metadata at runtime to perform all validations in one call.
TypeORM entities: @Entity(), @Column(), @PrimaryGeneratedColumn(), @ManyToOne() are decorators that describe the database schema in terms of TypeScript classes — TypeORM reads the metadata to generate migrations and build queries.
Swagger/OpenAPI documentation: @ApiProperty(), @ApiTags(), @ApiResponse() from @nestjs/swagger automatically generate OpenAPI specs from decorated controllers and DTOs — zero manual YAML or JSON needed.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.