Custom Decorators and Metadata in Angular

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
Note: TypeScript decorators are a Stage 3 TC39 proposal. Angular currently uses the legacy TypeScript decorator implementation (with 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.
Tip: Method decorators that wrap async methods should propagate the return value correctly. A debounce decorator that replaces a method with a debounced function must ensure the wrapper still returns what callers expect. For Angular event handlers that return void, this is trivial. For methods that return Observables or Promises, the decorator must preserve the return type or document that it changes it. Test your decorators thoroughly with the actual return types they will encounter.
Warning: Decorators run at class definition time, not at instance creation time. A method decorator modifies the prototype โ€” this means all instances of the class share the decorated method. If your decorator stores state (like a debounce timeout ID), store it in a Map keyed by the instance (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 })

🧠 Test Yourself

A @Debounce(300) method decorator is applied to a class. Two instances of the class exist simultaneously. When both instances’ debounced method is called, what happens if the timeout is stored in a closure variable instead of a WeakMap?