TypeScript decorators are functions that annotate and modify classes, methods, properties, and parameters at declaration time. Angular is built on decorators โ @Component, @Injectable, @Input, @ViewChild are all decorators. Writing your own decorators enables you to package cross-cutting concerns โ role-based access checks, performance timing, automatic unsubscription, method memoization, debouncing โ into reusable, declarative annotations that can be applied to any class or method with a single line. This lesson covers building practical custom decorators for MEAN Stack Angular applications, with real examples from production patterns.
Decorator Types
| Type | Applied To | Receives | Example Use |
|---|---|---|---|
| Class decorator | Class declaration | Constructor function | Auto-unsubscribe, performance profiling |
| Method decorator | Class method | Prototype, method name, descriptor | Debounce, memoize, log calls, confirm before action |
| Property decorator | Class property | Prototype, property name | Auto-convert types, log access, add validation |
| Parameter decorator | Constructor/method parameter | Prototype, method name, param index | Custom DI tokens (like Angular’s own @Inject()) |
Decorator Factory Pattern
| Pattern | Example | When to Use |
|---|---|---|
| No arguments | @AutoUnsubscribe |
No configuration needed |
| With arguments (factory) | @Debounce(300) |
Configurable behaviour |
| Optional arguments | @Log({ level: 'debug' }) |
Configuration with sensible defaults |
| Required arguments | @RequireRole('admin') |
Parametric enforcement |
experimentalDecorators: true in tsconfig). Angular 17 began transitioning to the new decorator spec in parallel. The decorator patterns in this lesson use the established approach that works with all current Angular versions. The fundamental concepts โ wrapping methods, modifying prototypes, adding metadata โ remain valid across both specifications.this), not in a closure variable that would be shared across all instances. Shared mutable closure state in decorators is a common source of subtle bugs in multi-instance scenarios.Complete Custom Decorator Examples
// โโ 1. @Debounce โ debounce method calls โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function Debounce(delay = 300) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;
// Use a Map to store timeout per instance (not a shared closure var)
const timers = new WeakMap<object, ReturnType<typeof setTimeout>>();
descriptor.value = function (this: object, ...args: any[]) {
const existing = timers.get(this);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
originalMethod.apply(this, args);
timers.delete(this);
}, delay);
timers.set(this, timer);
};
return descriptor;
};
}
// Usage โ no more manual setTimeout in the component:
@Component({ ... })
export class SearchComponent {
@Debounce(400)
onSearchInput(event: Event): void {
const query = (event.target as HTMLInputElement).value;
this.store.search(query);
// This runs 400ms after the user stops typing โ not on every keystroke
}
}
// โโ 2. @Memoize โ cache method results โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function Memoize() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;
const cache = new Map<string, any>();
descriptor.value = function (...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = originalMethod.apply(this, args);
cache.set(key, result);
return result;
};
return descriptor;
};
}
// Usage โ expensive computation cached on first call:
@Injectable({ providedIn: 'root' })
export class TaskAnalyticsService {
@Memoize()
computeProductivity(tasks: Task[], days: number): ProductivityReport {
// Expensive calculation โ only computed once per unique args combination
// ...
}
}
// โโ 3. @ConfirmAction โ require user confirmation before proceeding โโโโโโโโโ
export function ConfirmAction(message = 'Are you sure?') {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = function (this: any, ...args: any[]) {
if (window.confirm(message)) {
return originalMethod.apply(this, args);
}
// User cancelled โ return undefined
};
return descriptor;
};
}
// For Angular โ use a dialog service instead of window.confirm:
export function ConfirmDialog(message: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = function (this: any, ...args: any[]) {
// Inject dialog service at call time (instance must have dialogService)
const dialog = (this as any).dialogService as DialogService;
if (!dialog) {
console.warn('@ConfirmDialog requires dialogService to be injected');
return originalMethod.apply(this, args);
}
dialog.confirm(message).subscribe(confirmed => {
if (confirmed) originalMethod.apply(this, args);
});
};
return descriptor;
};
}
// Usage:
@Component({ ... })
export class TaskListComponent {
dialogService = inject(DialogService);
@ConfirmDialog('Delete this task? This cannot be undone.')
deleteTask(taskId: string): void {
this.store.delete(taskId);
}
}
// โโ 4. @LogMethod โ log method calls and timing โโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function LogMethod(options: { level?: 'log' | 'warn' | 'error'; label?: string } = {}) {
const { level = 'log', label } = options;
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
if (!isDevMode()) return descriptor; // skip in production
const originalMethod = descriptor.value;
const methodLabel = label ?? `${target.constructor.name}.${propertyKey}`;
descriptor.value = function (...args: any[]) {
const start = performance.now();
console[level](`[${methodLabel}] called with:`, args);
const result = originalMethod.apply(this, args);
// Handle Promise return types
if (result instanceof Promise) {
return result.then(val => {
console[level](`[${methodLabel}] resolved in ${(performance.now() - start).toFixed(2)}ms`);
return val;
});
}
console[level](`[${methodLabel}] returned in ${(performance.now() - start).toFixed(2)}ms`);
return result;
};
return descriptor;
};
}
// โโ 5. Property decorator โ @LocalStorage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function LocalStorage<T>(key: string, defaultValue: T) {
return function (target: any, propertyName: string): void {
const storageKey = `app_${key}`;
Object.defineProperty(target, propertyName, {
get(): T {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : defaultValue;
},
set(value: T): void {
localStorage.setItem(storageKey, JSON.stringify(value));
},
enumerable: true,
configurable: true,
});
};
}
// Usage โ property automatically synced to localStorage:
@Injectable({ providedIn: 'root' })
export class UserPreferencesService {
@LocalStorage('theme', 'light')
theme!: string;
@LocalStorage('language', 'en')
language!: string;
// Read: this.prefs.theme โ reads from localStorage
// Write: this.prefs.theme = 'dark' โ writes to localStorage
}
How It Works
Step 1 โ Method Decorators Replace the Original Function
A method decorator receives the class prototype, the method name, and a property descriptor object containing the original function as descriptor.value. By replacing descriptor.value with a new function that wraps the original, the decorator intercepts every call to that method. The original function is called via originalMethod.apply(this, args) โ preserving the correct this context and passing through all arguments.
Step 2 โ Decorator Factories Return Decorators
When you write @Debounce(300), TypeScript calls Debounce(300) first โ this is the factory. The factory returns the actual decorator function. The returned decorator receives the method descriptor and modifies it. This two-level function pattern (factory returns decorator returns descriptor) is how all parameterised decorators work. No-argument decorators can be written as direct functions: @Log where Log is the decorator function itself.
Step 3 โ WeakMap Ensures Per-Instance State
Decorators modify the prototype, which is shared across all instances. If a debounce decorator stores its timeout ID in a simple closure variable, all instances share one timer โ pressing a button in component A resets the debounce timer for component B. Using a WeakMap keyed by this (the instance) stores separate state for each instance. WeakMap also does not prevent garbage collection of instances when they are destroyed โ avoiding memory leaks.
Step 4 โ Property Decorators Use Object.defineProperty
Property decorators run at class definition time, before any instance is created. They typically use Object.defineProperty(target, propertyName, { get, set }) to replace the simple property with a getter/setter pair on the prototype. When an instance reads or writes the property, the getter/setter intercepts the access. The @LocalStorage decorator makes a class property transparently backed by localStorage without any explicit serialisation/deserialisation code in the class itself.
Step 5 โ isDevMode() Enables Development-Only Decorators
Angular’s isDevMode() function returns true during development and false in production builds. Decorators like @LogMethod should only activate in development โ logging every method call in production wastes CPU cycles and potentially leaks sensitive data to the browser console. Adding an early if (!isDevMode()) return descriptor before modifying the descriptor short-circuits the decoration entirely in production builds.
Common Mistakes
Mistake 1 โ Storing debounce timer in a closure (shared across instances)
โ Wrong โ timer shared between all component instances:
export function Debounce(delay: number) {
let timer: ReturnType<typeof setTimeout>; // shared across ALL instances!
return (target: any, key: string, descriptor: PropertyDescriptor) => {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
clearTimeout(timer);
timer = setTimeout(() => original.apply(this, args), delay);
};
return descriptor;
};
}
✅ Correct โ use WeakMap for per-instance storage:
const timers = new WeakMap<object, ReturnType<typeof setTimeout>>();
// (shown in full example above)
Mistake 2 โ Not preserving this context with apply()
โ Wrong โ arrow function loses this context:
descriptor.value = (...args: any[]) => {
return originalMethod(...args); // this is undefined โ lost!
};
✅ Correct โ use apply to preserve instance context:
descriptor.value = function(this: object, ...args: any[]) {
return originalMethod.apply(this, args); // this correctly bound
};
Mistake 3 โ Running expensive decorator logic in production
โ Wrong โ logging decorator runs in production builds:
export function LogMethod() {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
const orig = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(key, 'called'); // logs to console in production!
return orig.apply(this, args);
};
return descriptor;
};
}
✅ Correct โ skip in production:
export function LogMethod() {
return (target: any, key: string, descriptor: PropertyDescriptor) => {
if (!isDevMode()) return descriptor; // no-op in production
// ... wrapping logic
};
}
Quick Reference
| Decorator Type | Signature |
|---|---|
| Method decorator (no args) | function MyDec(target: any, key: string, desc: PropertyDescriptor): PropertyDescriptor |
| Method decorator factory | function MyDec(options): (target, key, desc) => PropertyDescriptor |
| Class decorator | function MyDec(constructor: Function): void | Function |
| Property decorator | function MyDec(target: any, key: string): void |
| Preserve this in wrapper | descriptor.value = function(this: object, ...args) { orig.apply(this, args) } |
| Per-instance state | const map = new WeakMap(); map.set(this, state) |
| Dev-only decorator | if (!isDevMode()) return descriptor |
| Property with get/set | Object.defineProperty(target, key, { get, set, enumerable: true }) |