TL;DR
TypeScript decorators are special syntax (@expression) that attach to classes, methods, properties, or parameters to add metadata or modify behavior at design time. TypeScript 5.0 ships TC39 Stage 3 decorators natively (no config needed); older frameworks use legacy experimentalDecorators. Key patterns: @log wraps methods; @Injectable registers DI tokens; @Column maps ORM fields. Use our TypeScript converter to check your decorator output.
Key Takeaways
- TC39 decorators (TS 5.0+) need no tsconfig flag; legacy decorators require
experimentalDecorators: true - Class decorators can replace or extend a class; method decorators wrap function behavior
- Decorator factories enable parameterized decorators:
@retry(3, 500) - Stacked decorators evaluate top-to-bottom but execute bottom-to-top (function composition order)
reflect-metadata+emitDecoratorMetadataunlocks runtime type reflection for DI containers- NestJS, Angular, TypeORM all rely on legacy experimental decorators — migration to TC39 is ongoing
- The new
accessorkeyword (TS 5.0) enables observable/reactive field patterns without manual getters
What Are TypeScript Decorators?
Decorators are a special kind of declaration that can be attached to a class, method, accessor, property, or parameter using the @expression syntax. The expression must evaluate to a function that is called at runtime with information about the decorated declaration. Decorators originated from Python's decorator pattern and Java's annotations, and were popularized in the TypeScript ecosystem by Angular and NestJS before becoming a formal ECMAScript proposal.
The core appeal of decorators is declarative, cross-cutting behavior: instead of manually wrapping every method call in a try/catch for logging, you attach @log once. Instead of manually registering every service in a DI container, you annotate the class with @Injectable(). This separation of concerns is the foundation of Aspect-Oriented Programming (AOP) in TypeScript.
TC39 Proposal: Stage 3 Status
The ECMAScript decorators proposal has had a long history. An early Stage 1 design was what TypeScript's legacy experimentalDecorators implemented. The proposal was substantially redesigned, and in 2022 it reached Stage 3 — meaning the API is finalized and implementations can begin. TypeScript 5.0 (released March 2023) was the first TypeScript version to implement TC39 Stage 3 decorators. The two systems are incompatible: you cannot mix them in the same file, and each has distinct APIs.
Enabling Decorators in tsconfig.json
// tsconfig.json — TC39 Standard Decorators (TypeScript 5.0+)
// No special flag is needed. Just target ES2022 or higher.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"strict": true
}
}
// tsconfig.json — Legacy Experimental Decorators
// Required by Angular, NestJS, TypeORM, MobX, class-validator
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true, // enables runtime type reflection
"strict": true
}
}
// Install reflect-metadata polyfill for DI patterns (legacy only)
// npm install reflect-metadata
// Then add at your app entry point (e.g., main.ts):
import 'reflect-metadata';Class Decorators
A class decorator is applied to the class constructor. In TC39 decorators, the signature is (target: Function, context: ClassDecoratorContext) => Function | void. You can return a new class (replacing the original) or modify the original in place. In legacy decorators, the signature is simply (constructor: Function) => void or a new constructor.
Adding Metadata (TC39)
// TC39 class decorator: attach metadata via context.metadata
function entity(tableName: string) {
return function <T extends abstract new (...args: any[]) => {}>(
target: T,
context: ClassDecoratorContext
) {
// context.metadata is a shared object for this class hierarchy
context.metadata['tableName'] = tableName;
context.metadata['entity'] = true;
return target;
};
}
function column(options: { type: string; nullable?: boolean }) {
return function (_value: undefined, context: ClassFieldDecoratorContext) {
context.metadata['columns'] ??= [];
(context.metadata['columns'] as any[]).push({
name: context.name,
...options,
});
};
}
@entity('users')
class User {
@column({ type: 'varchar' })
name: string = '';
@column({ type: 'varchar', nullable: false })
email: string = '';
}
// Access metadata at runtime
const meta = (User as any)[Symbol.metadata];
console.log(meta.tableName); // 'users'
console.log(meta.columns); // [{ name: 'name', type: 'varchar' }, ...]Modifying Class Behavior: Sealed and Frozen
// Prevent modification of class and its prototype
function sealed<T extends abstract new (...args: any[]) => {}>(
target: T,
context: ClassDecoratorContext
) {
Object.seal(target);
Object.seal(target.prototype);
}
// Add automatic timestamp fields to any class
function withTimestamps<T extends new (...args: any[]) => {}>(
target: T,
context: ClassDecoratorContext
) {
return class extends target {
readonly createdAt: Date = new Date();
updatedAt: Date = new Date();
touch() {
(this as any).updatedAt = new Date();
}
};
}
@sealed
@withTimestamps
class Product {
constructor(
public name: string,
public price: number
) {}
}
const p = new Product('Widget', 9.99);
console.log((p as any).createdAt); // Date object
(p as any).touch(); // updates updatedAtSingleton Pattern with Class Decorator
// Singleton: ensure only one instance exists per class
function singleton<T extends new (...args: any[]) => {}>(
target: T,
context: ClassDecoratorContext
) {
let instance: InstanceType<T> | undefined;
return new Proxy(target, {
construct(target, args) {
if (!instance) {
instance = Reflect.construct(target, args) as InstanceType<T>;
}
return instance!;
},
}) as T;
}
@singleton
class DatabaseConnection {
readonly id = Math.random();
constructor(public connectionString: string) {
console.log('Creating new connection to', connectionString);
}
query(sql: string) { /* ... */ }
}
const db1 = new DatabaseConnection('postgres://localhost/mydb');
const db2 = new DatabaseConnection('postgres://localhost/other');
console.log(db1 === db2); // true — same instance
console.log(db1.id === db2.id); // trueMethod Decorators
Method decorators intercept method calls. In TC39 decorators, a method decorator receives (target: Function, context: ClassMethodDecoratorContext) and returns a replacement function or void. The context object provides name, kind, static, private, and addInitializer. This is where decorators shine for cross-cutting concerns.
Logging and Timing
// Structured logging decorator (TC39)
function log(options: { level?: 'info' | 'warn' | 'error' } = {}) {
return function (target: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
const level = options.level ?? 'info';
return function (this: any, ...args: any[]) {
const start = performance.now();
console[level](`[${methodName}] called with`, args);
try {
const result = target.call(this, ...args);
// Handle promises transparently
if (result instanceof Promise) {
return result
.then((val) => {
const ms = (performance.now() - start).toFixed(2);
console[level](`[${methodName}] resolved in ${ms}ms →`, val);
return val;
})
.catch((err) => {
console.error(`[${methodName}] rejected after`,
(performance.now() - start).toFixed(2), 'ms:', err);
throw err;
});
}
const ms = (performance.now() - start).toFixed(2);
console[level](`[${methodName}] returned in ${ms}ms →`, result);
return result;
} catch (err) {
console.error(`[${methodName}] threw:`, err);
throw err;
}
};
};
}
// Standalone timing decorator
function timing(target: Function, context: ClassMethodDecoratorContext) {
const name = String(context.name);
return async function (this: any, ...args: any[]) {
const start = performance.now();
try {
return await target.call(this, ...args);
} finally {
console.log(`${name}: ${(performance.now() - start).toFixed(2)}ms`);
}
};
}
class ReportService {
@log({ level: 'info' })
generateReport(userId: string, type: string) {
return { userId, type, data: [] };
}
@timing
async fetchFromDatabase(query: string): Promise<any[]> {
return []; // simulate DB call
}
}Memoization
// Memoize caches method results keyed by serialized arguments
function memoize(
target: Function,
context: ClassMethodDecoratorContext
) {
// Each instance gets its own cache via WeakMap
const cacheMap = new WeakMap<object, Map<string, any>>();
return function (this: any, ...args: any[]) {
if (!cacheMap.has(this)) {
cacheMap.set(this, new Map());
}
const cache = cacheMap.get(this)!;
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = target.call(this, ...args);
cache.set(key, result);
return result;
};
}
// TTL (time-to-live) cache: expire results after N milliseconds
function cache(ttlMs: number) {
return function (target: Function, context: ClassMethodDecoratorContext) {
const store = new Map<string, { value: any; expiresAt: number }>();
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args);
const entry = store.get(key);
if (entry && Date.now() < entry.expiresAt) {
return entry.value;
}
const value = target.call(this, ...args);
store.set(key, { value, expiresAt: Date.now() + ttlMs });
return value;
};
};
}
class MathUtils {
@memoize
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
@cache(60_000) // cache results for 1 minute
expensiveCalc(x: number, y: number): number {
// Simulate heavy computation
return x ** y;
}
}
const mu = new MathUtils();
console.log(mu.fibonacci(40)); // fast due to memoizationRate Limiting and Retry
// Rate limit: cap calls to N per windowMs
function rateLimit(maxCalls: number, windowMs: number) {
return function (target: Function, context: ClassMethodDecoratorContext) {
const calls: number[] = [];
return function (this: any, ...args: any[]) {
const now = Date.now();
// Evict calls outside the sliding window
while (calls.length && calls[0] < now - windowMs) calls.shift();
if (calls.length >= maxCalls) {
const waitMs = calls[0]! + windowMs - now;
throw new Error(
"Rate limit: max " + maxCalls + " calls/" + windowMs + "ms. " +
"Retry in " + waitMs + "ms"
);
}
calls.push(now);
return target.call(this, ...args);
};
};
}
// Retry with exponential backoff
function retry(maxAttempts: number, baseDelayMs = 200) {
return function (target: Function, context: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await target.call(this, ...args);
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
const delay = baseDelayMs * 2 ** (attempt - 1); // exponential
console.warn(
`Attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`
);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastError;
};
};
}
class ExternalApiService {
@rateLimit(10, 60_000) // max 10 requests per minute
@retry(3, 500) // retry up to 3 times with backoff
async callApi(endpoint: string): Promise<unknown> {
const res = await fetch(endpoint);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
}Property Decorators
Property decorators in TC39 decorators are called field decorators. A field decorator has the signature (_value: undefined, context: ClassFieldDecoratorContext) => initializer | void. Returning a function acts as an initializer: it receives the initial value and returns the actual value to store. This enables validation, transformation, and default injection at instance creation time.
Validation and Transformation
// Required field: throw if initial value is falsy
function required(_value: undefined, context: ClassFieldDecoratorContext) {
return function (this: any, initial: any) {
if (initial === undefined || initial === null || initial === '') {
throw new Error(`Field "${String(context.name)}" is required`);
}
return initial;
};
}
// Min-length string validator
function minLength(min: number) {
return function (_value: undefined, context: ClassFieldDecoratorContext) {
return function (this: any, initial: string) {
if (typeof initial === 'string' && initial.length < min) {
throw new Error(
`Field "${String(context.name)}" must be >= ${min} chars (got ${initial.length})`
);
}
return initial;
};
};
}
// Trim whitespace on initialization
function trim(_value: undefined, context: ClassFieldDecoratorContext) {
return function (this: any, initial: string) {
return typeof initial === 'string' ? initial.trim() : initial;
};
}
// Transform to lowercase
function lowercase(_value: undefined, context: ClassFieldDecoratorContext) {
return function (this: any, initial: string) {
return typeof initial === 'string' ? initial.toLowerCase() : initial;
};
}
// Clamp number to a range
function clamp(min: number, max: number) {
return function (_value: undefined, context: ClassFieldDecoratorContext) {
return function (this: any, initial: number) {
return Math.max(min, Math.min(max, initial));
};
};
}
class UserProfile {
@required
@minLength(2)
@trim
name: string = ' Alice '; // stored as 'Alice'
@lowercase
@trim
email: string = ' ALICE@EX.COM '; // stored as 'alice@ex.com'
@clamp(0, 150)
age: number = 200; // clamped to 150
@required
id: string = crypto.randomUUID();
}
const profile = new UserProfile();
console.log(profile.name); // 'Alice'
console.log(profile.email); // 'alice@ex.com'
console.log(profile.age); // 150Parameter Decorators
Parameter decorators are only available in legacy experimental decorators — TC39 Stage 3 decorators do not yet include parameter decorators. They are heavily used in NestJS for route parameter extraction (@Param(), @Body(), @Query()) and in InversifyJS for named injection tokens. A parameter decorator has the signature (target: Object, propertyKey: string | symbol, parameterIndex: number) => void.
// Legacy parameter decorators (requires experimentalDecorators: true)
import 'reflect-metadata';
const PARAM_METADATA_KEY = Symbol('param_metadata');
interface ParamMetadata {
index: number;
key: string;
source: 'body' | 'query' | 'param' | 'header';
}
// @Body('key') — extract field from request body
function Body(key?: string) {
return function (
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) {
const existing: ParamMetadata[] =
Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
existing.push({ index: parameterIndex, key: key ?? '', source: 'body' });
Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
};
}
// @Query('key') — extract field from query string
function Query(key: string) {
return function (
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) {
const existing: ParamMetadata[] =
Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
existing.push({ index: parameterIndex, key, source: 'query' });
Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
};
}
// @Param('id') — extract URL route parameter
function Param(key: string) {
return function (
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) {
const existing: ParamMetadata[] =
Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
existing.push({ index: parameterIndex, key, source: 'param' });
Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
};
}
class UserController {
createUser(
@Body('name') name: string,
@Body('email') email: string,
@Query('role') role: string
) {
return { name, email, role };
}
getUser(@Param('id') id: string) {
return { id };
}
}Accessor Decorators and the accessor Keyword
TypeScript 5.0 introduced a new accessor keyword that auto-generates a getter/setter pair backed by a private storage field. An accessor decorator receives a ClassAccessorDecoratorTarget and returns a ClassAccessorDecoratorResult with optional get, set, and init hooks. This is the cleanest way to implement observable/reactive properties.
// TC39 accessor decorator: observable reactive fields
type ChangeListener<T> = (oldValue: T, newValue: T, name: string) => void;
const listeners = new WeakMap<object, Map<string, ChangeListener<any>[]>>();
function observable(
target: ClassAccessorDecoratorTarget<any, any>,
context: ClassAccessorDecoratorContext
) {
const { get: originalGet, set: originalSet } = target;
const name = String(context.name);
return {
get(this: any) {
return originalGet.call(this);
},
set(this: any, newValue: any) {
const oldValue = originalGet.call(this);
if (oldValue !== newValue) {
originalSet.call(this, newValue);
const listenerMap = listeners.get(this);
listenerMap?.get(name)?.forEach((fn) => fn(oldValue, newValue, name));
}
},
init(this: any, value: any) {
if (!listeners.has(this)) listeners.set(this, new Map());
return value;
},
};
}
// Validated accessor: enforce constraints on setter
function validated(validator: (v: any) => boolean, message: string) {
return function (
target: ClassAccessorDecoratorTarget<any, any>,
context: ClassAccessorDecoratorContext
) {
const { get, set } = target;
return {
get(this: any) { return get.call(this); },
set(this: any, value: any) {
if (!validator(value)) {
throw new RangeError(
`Invalid value "${value}" for "${String(context.name)}": ${message}`
);
}
set.call(this, value);
},
};
};
}
class Store {
@observable
accessor count = 0;
@observable
@validated((v) => v.length >= 2, 'must be at least 2 characters')
accessor username = 'guest';
increment() { this.count++; }
decrement() { this.count = Math.max(0, this.count - 1); }
}
const store = new Store();
// Attach listener (requires helper not shown for brevity)
store.count = 5; // triggers observable set
store.username = 'A'; // throws: must be at least 2 charactersDecorator Factories: Parameterized Decorators
A decorator factory is a function that returns a decorator. This is the standard way to pass configuration to a decorator. When you write @MinLength(8), TypeScript first calls MinLength(8), which returns the actual decorator function that is then applied to the target. All of TypeScript's most popular decorator usage — NestJS routes, TypeORM columns, class-validator rules — uses decorator factories.
// Configurable debounce decorator factory
function debounce(waitMs: number) {
return function (target: Function, context: ClassMethodDecoratorContext) {
let timer: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timer);
timer = setTimeout(() => target.call(this, ...args), waitMs);
};
};
}
// Deprecation warning decorator factory
function deprecated(message?: string) {
return function (target: Function, context: ClassMethodDecoratorContext) {
const name = String(context.name);
return function (this: any, ...args: any[]) {
console.warn(
"DEPRECATED: " + name + " is deprecated" + (message ? ': ' + message : '') + ". " +
"Avoid using it in new code."
);
return target.call(this, ...args);
};
};
}
// Authorization guard decorator factory
function authorize(...requiredRoles: string[]) {
return function (target: Function, context: ClassMethodDecoratorContext) {
const name = String(context.name);
return function (this: any, ...args: any[]) {
const userRoles: string[] = this.currentUser?.roles ?? [];
const hasRole = requiredRoles.some((r) => userRoles.includes(r));
if (!hasRole) {
throw new Error(
`Unauthorized: ${name} requires roles: ${requiredRoles.join(', ')}`
);
}
return target.call(this, ...args);
};
};
}
class SearchComponent {
currentUser = { roles: ['admin'] };
@debounce(300)
onSearchInput(query: string) {
console.log('Searching for:', query);
}
@deprecated('Use findById instead')
findUser(id: number) {
return { id };
}
@authorize('admin', 'moderator')
deleteContent(id: string) {
return `Deleted ${id}`;
}
}Decorator Composition: Order of Execution
When multiple decorators are applied to a single declaration, they follow function composition order: factories are evaluated top-to-bottom, but the resulting decorator functions are executed bottom-to-top. This is identical to f(g(method)) where @f is listed above @g. Understanding this order is critical when decorators depend on each other.
// Demonstrating decorator composition order
function trace(label: string) {
return function (target: Function, context: ClassMethodDecoratorContext) {
console.log(`Evaluating decorator: ${label}`);
return function (this: any, ...args: any[]) {
console.log(`Entering: ${label}`);
const result = target.call(this, ...args);
console.log(`Exiting: ${label}`);
return result;
};
};
}
class Pipeline {
@trace('A') // Evaluated 1st, called 1st (outermost)
@trace('B') // Evaluated 2nd, called 2nd
@trace('C') // Evaluated 3rd, called 3rd (innermost, wraps original)
process(data: string) {
console.log('Processing:', data);
return data.toUpperCase();
}
}
// Output when pipeline.process('hello') is called:
// Evaluating decorator: A
// Evaluating decorator: B
// Evaluating decorator: C
// Entering: A
// Entering: B
// Entering: C
// Processing: hello
// Exiting: C
// Exiting: B
// Exiting: A
// Real-world composition pattern:
// Order matters — validate before authorize, log after both
class OrderService {
@log() // outermost wrapper
@authorize('user')
@validate({ amount: (v) => v > 0 }) // innermost, runs first
async createOrder(input: { amount: number; item: string }) {
return { orderId: crypto.randomUUID(), ...input };
}
}Metadata Reflection: reflect-metadata and emitDecoratorMetadata
The reflect-metadata package polyfills the Reflect.metadata API, enabling decorators to read and write metadata on class constructors and their members. When emitDecoratorMetadata: true is set in tsconfig, TypeScript automatically emits three metadata keys at compile time:
design:type— the declared type of a propertydesign:paramtypes— array of constructor/method parameter typesdesign:returntype— return type of a method
// reflect-metadata usage (legacy decorators)
import 'reflect-metadata';
// Custom metadata keys
const VALIDATION_KEY = Symbol('validations');
const INJECTABLE_KEY = Symbol('injectable');
// Store validation rules in metadata
function IsString(_target: Object, propertyKey: string | symbol) {
const rules = Reflect.getMetadata(VALIDATION_KEY, _target, propertyKey) ?? [];
rules.push({ type: 'string' });
Reflect.defineMetadata(VALIDATION_KEY, rules, _target, propertyKey);
}
function IsNumber(_target: Object, propertyKey: string | symbol) {
const rules = Reflect.getMetadata(VALIDATION_KEY, _target, propertyKey) ?? [];
rules.push({ type: 'number' });
Reflect.defineMetadata(VALIDATION_KEY, rules, _target, propertyKey);
}
// Read emitted design:paramtypes to discover dependencies
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata(INJECTABLE_KEY, true, target);
const types = Reflect.getMetadata('design:paramtypes', target) ?? [];
console.log(`${target.name} depends on:`, types.map((t: any) => t.name));
};
}
class Logger {
log(message: string) { console.log(`[LOG] ${message}`); }
}
class Database {
query(sql: string) { return []; }
}
@Injectable()
class UserService {
constructor(
private logger: Logger, // TypeScript emits Logger in design:paramtypes
private db: Database // TypeScript emits Database in design:paramtypes
) {}
}
// Console: "UserService depends on: ['Logger', 'Database']"
// Validate an object against its stored metadata rules
function validateObject<T extends object>(obj: T): string[] {
const errors: string[] = [];
for (const key of Object.keys(obj) as (keyof T)[]) {
const rules = Reflect.getMetadata(VALIDATION_KEY, obj, key as string) ?? [];
const value = obj[key];
for (const rule of rules) {
if (rule.type === 'string' && typeof value !== 'string') {
errors.push(`${String(key)} must be a string`);
}
if (rule.type === 'number' && typeof value !== 'number') {
errors.push(`${String(key)} must be a number`);
}
}
}
return errors;
}Real-World Patterns: NestJS and TypeORM
NestJS Dependency Injection Decorators
// NestJS-style decorators (simplified implementation)
import 'reflect-metadata';
const MODULE_METADATA = {
imports: 'imports',
controllers: 'controllers',
providers: 'providers',
exports: 'exports',
};
function Module(metadata: {
imports?: any[];
controllers?: any[];
providers?: any[];
exports?: any[];
}) {
return function (target: Function) {
for (const [key, value] of Object.entries(metadata)) {
Reflect.defineMetadata(key, value, target);
}
};
}
function Controller(path: string): ClassDecorator {
return (target) => Reflect.defineMetadata('path', path, target);
}
function Injectable(): ClassDecorator {
return (target) => Reflect.defineMetadata('injectable', true, target);
}
function Get(path: string): MethodDecorator {
return (target, key, descriptor) => {
Reflect.defineMetadata('method', 'GET', descriptor.value!);
Reflect.defineMetadata('path', path, descriptor.value!);
};
}
function Post(path: string): MethodDecorator {
return (target, key, descriptor) => {
Reflect.defineMetadata('method', 'POST', descriptor.value!);
Reflect.defineMetadata('path', path, descriptor.value!);
};
}
function UseGuards(...guards: Function[]): MethodDecorator {
return (target, key, descriptor) => {
Reflect.defineMetadata('guards', guards, descriptor.value!);
};
}
function UseInterceptors(...interceptors: Function[]): MethodDecorator {
return (target, key, descriptor) => {
Reflect.defineMetadata('interceptors', interceptors, descriptor.value!);
};
}
@Injectable()
class UserRepository {
async findById(id: string) { return { id, name: 'Alice' }; }
async save(user: any) { return user; }
}
@Injectable()
class UserService {
constructor(private readonly userRepo: UserRepository) {}
async getUser(id: string) { return this.userRepo.findById(id); }
}
@Controller('/users')
class UserController {
constructor(private readonly userService: UserService) {}
@Get('/')
@UseInterceptors(LoggingInterceptor)
findAll() { return this.userService.getUser('all'); }
@Get('/:id')
findOne() { return {}; }
@Post('/')
@UseGuards(AuthGuard, RolesGuard)
create() { return {}; }
}
@Module({
controllers: [UserController],
providers: [UserService, UserRepository],
exports: [UserService],
})
class UserModule {}TypeORM Entity Decorators
// TypeORM decorator patterns (real usage)
import {
Entity, Column, PrimaryGeneratedColumn, Index,
CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
ManyToOne, OneToMany, JoinColumn, BeforeInsert, BeforeUpdate,
} from 'typeorm';
@Entity('users')
@Index(['email'], { unique: true })
class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
firstName: string;
@Column({ type: 'varchar', length: 100 })
lastName: string;
@Column({ type: 'varchar', length: 255, unique: true })
email: string;
@Column({ type: 'varchar', select: false }) // excluded from SELECT by default
passwordHash: string;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, unknown> | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn()
deletedAt: Date | null; // enables soft delete
@OneToMany(() => Post, (post) => post.author, { cascade: ['insert', 'update'] })
posts: Post[];
@BeforeInsert()
@BeforeUpdate()
normalizeEmail() {
this.email = this.email.toLowerCase().trim();
}
}
@Entity('posts')
class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 500 })
title: string;
@Column({ type: 'text' })
body: string;
@Column({ type: 'varchar', length: 500, nullable: true })
slug: string | null;
@ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: User;
@CreateDateColumn()
createdAt: Date;
}TC39 Standard vs Legacy Decorators: Full Comparison
The two decorator systems are not interchangeable. The table below summarizes every significant difference to help you decide which to use and how to migrate.
| Feature | Legacy (experimentalDecorators) | TC39 Stage 3 (TS 5.0+) |
|---|---|---|
| tsconfig flag | experimentalDecorators: true | None required |
| TypeScript version | All versions supporting decorators | 5.0+ |
| Class decorator signature | (constructor: T) => T | void | (target: T, ctx: ClassDecoratorContext) => T | void |
| Method decorator signature | (target, key, descriptor) | (fn: Function, ctx: ClassMethodDecoratorContext) => Function | void |
| Field/property decorator | PropertyDecorator (no initializer) | Returns initializer function (receives/returns initial value) |
| Accessor decorator | Decorates explicit get/set | Decorates "accessor" auto-accessor fields |
| Parameter decorators | Supported | Not yet in spec |
| Metadata API | Reflect.metadata / Reflect.defineMetadata | context.metadata (shared object) |
| emitDecoratorMetadata | Supported (design:paramtypes etc.) | Not applicable |
| Auto-accessor keyword | Not available | "accessor" keyword |
| Decorator order | Same | Same (bottom-to-top execution) |
| Private fields (#) | Limited support | Full support via context.private |
| Static members | Supported | Supported via context.static |
| Framework support | Angular, NestJS, TypeORM, MobX, class-validator | New libraries; major frameworks migrating |
| Stability | Frozen (no new features) | Actively maintained, future additions expected |
Building a Mini DI Container with Decorators
To solidify everything, here is a complete, working mini dependency injection container built entirely with legacy decorators and reflect-metadata. This is the conceptual core of NestJS's and InversifyJS's IoC containers.
// mini-di.ts — A complete DI container in ~80 lines
import 'reflect-metadata';
// ── Container ──────────────────────────────────────────────────────────
type Constructor<T = any> = new (...args: any[]) => T;
class Container {
private registry = new Map<string | symbol, Constructor>();
private singletons = new Map<string | symbol, any>();
register(token: string | symbol, implementation: Constructor) {
this.registry.set(token, implementation);
}
resolve<T>(token: string | symbol | Constructor<T>): T {
// If passed a constructor directly, resolve by constructor
if (typeof token === 'function') {
return this.instantiate(token as Constructor<T>);
}
const Ctor = this.registry.get(token);
if (!Ctor) throw new Error(`No provider for token: ${String(token)}`);
return this.instantiate<T>(Ctor as Constructor<T>);
}
private instantiate<T>(Ctor: Constructor<T>): T {
// Check singleton cache
if (this.singletons.has(Ctor as any)) {
return this.singletons.get(Ctor as any);
}
// Read constructor parameter types emitted by TypeScript
const paramTypes: Constructor[] =
Reflect.getMetadata('design:paramtypes', Ctor) ?? [];
// Recursively resolve each dependency
const deps = paramTypes.map((dep) => {
if (!dep || dep === Object) {
throw new Error(
"Cannot resolve dependency of " + Ctor.name + ": " +
"check that all params are decorated classes with @Injectable"
);
}
return this.instantiate(dep);
});
const instance = new Ctor(...deps);
// If marked as singleton, cache it
if (Reflect.getMetadata('singleton', Ctor)) {
this.singletons.set(Ctor as any, instance);
}
return instance;
}
}
// Global container instance (can be module-scoped in real apps)
export const container = new Container();
// ── Decorators ──────────────────────────────────────────────────────────
function Injectable(): ClassDecorator {
return (target) => {
Reflect.defineMetadata('injectable', true, target);
};
}
function Singleton(): ClassDecorator {
return (target) => {
Reflect.defineMetadata('injectable', true, target);
Reflect.defineMetadata('singleton', true, target);
};
}
function Inject(token: string | symbol): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const tokens =
Reflect.getMetadata('inject:tokens', target, propertyKey!) ?? [];
tokens[parameterIndex] = token;
Reflect.defineMetadata('inject:tokens', tokens, target, propertyKey!);
};
}
// ── Services ───────────────────────────────────────────────────────────
@Singleton()
class ConfigService {
get(key: string): string {
return process.env[key] ?? '';
}
}
@Injectable()
class Logger {
constructor(private config: ConfigService) {}
log(message: string) {
const level = this.config.get('LOG_LEVEL') || 'INFO';
console.log(`[${level}] ${message}`);
}
}
@Injectable()
class DatabaseService {
constructor(
private logger: Logger,
private config: ConfigService
) {
this.logger.log('DatabaseService initialized');
}
query(sql: string) {
this.logger.log(`Executing: ${sql}`);
return [];
}
}
@Injectable()
class UserService {
constructor(
private db: DatabaseService,
private logger: Logger
) {}
findAll() {
this.logger.log('Finding all users');
return this.db.query('SELECT * FROM users');
}
}
// ── Bootstrap ──────────────────────────────────────────────────────────
// Resolve the entire tree automatically
const userService = container.resolve(UserService);
userService.findAll();
// Logs:
// [INFO] DatabaseService initialized
// [INFO] Finding all users
// [INFO] Executing: SELECT * FROM usersclass-validator Integration Pattern
class-validator is one of the most popular decorator-based validation libraries, used extensively in NestJS for DTO validation. It relies entirely on legacy experimental decorators and reflect-metadata. Here is the canonical pattern and how it integrates with NestJS pipes:
// class-validator + class-transformer pattern (NestJS DTOs)
import {
IsString, IsEmail, IsNumber, IsOptional, IsEnum,
MinLength, MaxLength, Min, Max, IsUrl, IsBoolean,
ValidateNested, IsArray, ArrayMinSize,
} from 'class-validator';
import { Type, Expose } from 'class-transformer';
import { validate } from 'class-validator';
enum UserRole {
Admin = 'admin',
User = 'user',
Moderator = 'moderator',
}
class AddressDto {
@IsString()
@MinLength(5)
street: string;
@IsString()
city: string;
@IsString()
@MinLength(2)
@MaxLength(2)
countryCode: string; // e.g. 'US'
}
class CreateUserDto {
@IsString()
@MinLength(2)
@MaxLength(50)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
@IsNumber()
@Min(13)
@Max(120)
age: number;
@IsEnum(UserRole)
@IsOptional()
role?: UserRole = UserRole.User;
@IsUrl()
@IsOptional()
website?: string;
@IsBoolean()
@IsOptional()
newsletter?: boolean;
@ValidateNested() // validates nested object
@Type(() => AddressDto) // needed for class-transformer
@IsOptional()
address?: AddressDto;
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
// Usage with class-transformer
import { plainToInstance } from 'class-transformer';
async function validateDto(plain: unknown) {
const dto = plainToInstance(CreateUserDto, plain);
const errors = await validate(dto);
if (errors.length > 0) {
const messages = errors.map((e) =>
Object.values(e.constraints ?? {}).join(', ')
);
throw new Error(`Validation failed: ${messages.join('; ')}`);
}
return dto;
}
// NestJS ValidationPipe handles this automatically:
// app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));Practical Decorator Patterns Reference
| Pattern | Decorator Type | Use Case | System |
|---|---|---|---|
| @log / @trace | Method | Structured logging, call tracking | Both |
| @timing / @benchmark | Method | Performance profiling | Both |
| @memoize / @cache(ttl) | Method | Expensive pure functions | Both |
| @retry(n, delay) | Method | Network/API resilience | Both |
| @rateLimit(n, ms) | Method | API throttling, DOS protection | Both |
| @debounce(ms) | Method | Search inputs, resize handlers | Both |
| @authorize(...roles) | Method | RBAC access control | Both |
| @deprecated(msg) | Method | API migration warnings | Both |
| @singleton | Class | Shared resources (DB, config) | Both |
| @sealed / @frozen | Class | Immutable classes | TC39 |
| @withTimestamps | Class | Audit fields on entities | Both |
| @observable | Accessor | Reactive state management | TC39 |
| @validated(fn) | Accessor / Field | Runtime type enforcement | TC39 |
| @required / @trim | Field | Data normalization on init | TC39 |
| @Injectable / @Singleton | Class | DI container registration | Legacy |
| @Controller(path) | Class | HTTP routing metadata | Legacy |
| @Get/@Post/@Put(path) | Method | Route handler registration | Legacy |
| @Entity / @Column | Class/Field | ORM schema mapping | Legacy |
| @IsEmail / @MinLength | Property | DTO input validation | Legacy |
| @Body / @Query / @Param | Parameter | Request data extraction | Legacy |
TypeScript Configuration Tips for Decorators
// Full production tsconfig for a NestJS project (legacy decorators)
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"declaration": true,
"strict": true,
"strictPropertyInitialization": false, // needed for entity classes
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"esModuleInterop": true
}
}
// Full tsconfig for TC39 decorators (new projects)
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"strict": true,
// No experimentalDecorators needed
// No emitDecoratorMetadata needed
"skipLibCheck": true
}
}
// Common pitfalls and fixes:
// 1. "Unable to resolve signature of class decorator"
// → Make sure target is ES2022+, not ES5
// → For TC39: check you're on TypeScript 5.0+
// 2. "Cannot read properties of undefined (reading 'prototype')"
// → You forgot: import 'reflect-metadata'; at app entry
// 3. "Parameter 'target' implicitly has an 'any' type"
// → Add explicit types to legacy decorator parameters
// 4. Decorator works in dev but not in build
// → ts-node uses tsconfig.json but ts-jest may not
// → Add: { preset: 'ts-jest', globals: { 'ts-jest': { tsconfig: 'tsconfig.json' } } }
// 5. "design:paramtypes" returns [Object] instead of real types
// → The parameter type must be a concrete class, not an interface
// → Interfaces are erased at runtime; use abstract classes or class tokensWhen to Use (and When Not to Use) Decorators
Good Use Cases
- Cross-cutting concerns — logging, caching, rate limiting, auth guards that apply to many methods across many classes
- Framework integration — when a framework requires decorators (NestJS, Angular, TypeORM), use the framework conventions
- Declarative schema definition — ORM column mapping, validation rules, OpenAPI spec generation where metadata-driven declaration is cleaner than imperative code
- Aspect-Oriented Programming — separating infrastructure concerns (persistence, monitoring, security) from business logic
- Observable/reactive state — TC39 accessor decorators for fine-grained reactivity without a full framework
Avoid Decorators When
- A higher-order function would be simpler and equally readable —
const wrappedFn = memoize(fn)is often clearer than@memoize fn()for standalone functions - The behavior is unique to one method in the entire codebase — adding a decorator for a single use case adds indirection without reuse benefit
- You are building a library and don't want to force users into a specific decorator system or tsconfig setup
- The team is unfamiliar with the execution order, and the debugging overhead outweighs the declarative benefit
- You need to decorate non-class code — plain functions, arrow functions, module exports — decorators simply cannot be applied there
Frequently Asked Questions
What is the difference between TC39 Stage 3 and legacy experimental decorators?
TC39 Stage 3 decorators (TypeScript 5.0+) need no tsconfig flag and use a new API with a context object. Legacy decorators use experimentalDecorators: true and the older (target, key, descriptor) API. They are incompatible and cannot be mixed in the same file. See the full comparison table above.
How do I enable decorators in tsconfig.json?
For TC39 decorators: set target to ES2022 or higher — no other flag needed. For legacy decorators: add "experimentalDecorators": true and optionally "emitDecoratorMetadata": true for DI metadata. Install reflect-metadata if using DI containers.
What is a decorator factory?
A function that returns a decorator, used when you need to pass parameters: @retry(3, 500) calls retry(3, 500) first, which returns the decorator function. All NestJS, TypeORM, and class-validator decorators are factories. Use factories for any configurable decorator behavior.
In what order do stacked decorators execute?
Factory expressions are evaluated top-to-bottom; the resulting decorators execute bottom-to-top. @A @B method means A is evaluated first but B wraps the method first, then A wraps the result: equivalent to A(B(method)). In practice: put the outermost behavior (logging) at the top, innermost (validation) at the bottom.
What is reflect-metadata and why is it needed?
A polyfill for the Reflect.metadata API that enables decorators to attach and retrieve metadata. When emitDecoratorMetadata: true is set, TypeScript emits design:paramtypes, design:type, and design:returntype metadata at compile time, which DI containers read at runtime to resolve dependencies. Import it once at your app entry point.
Can I use decorators on plain (non-class) functions?
No. Decorators can only be applied to classes, class methods, class fields, class accessors, and constructor parameters. For plain functions, use higher-order functions: const logged = withLog(myFunction). This is often cleaner for standalone utilities anyway.
How do I build a DI container with decorators?
Use @Injectable() to mark classes, emitDecoratorMetadata to emit constructor types, and a Container.resolve() method that reads Reflect.getMetadata('design:paramtypes', Ctor) to recursively instantiate dependencies. See the full working example in the Mini DI Container section above.
What are accessor decorators and the "accessor" keyword?
Introduced in TypeScript 5.0, the accessor keyword auto-generates a getter/setter backed by a private field: accessor name = 'value'. Accessor decorators can intercept both reads and writes, enabling reactive/observable patterns. This is the TC39 standard replacement for manually writing get/set pairs with legacy decorators.
For more TypeScript content, explore our TypeScript Generics Guide, TypeScript Utility Types Reference, TypeScript Best Practices 2026, and try our JSON to TypeScript Converter and TypeScript to JavaScript Converter.