🔷 TypeScript Q75 / 101

Explain advanced decorator patterns.

AI-Powered Answer ✓ Answered

TypeScript decorators provide a powerful way to add annotations and metadata to classes, methods, properties, and parameters, enabling meta-programming capabilities. While basic decorators modify or observe declarations, advanced patterns leverage their full potential for cross-cutting concerns, dependency injection, and complex object transformations.

1. Decorator Factories for Parameterization

Decorator factories are functions that return a decorator. This allows custom parameters to be passed into the decorator at declaration time, enabling more flexible and reusable decorators. The returned function is the actual decorator, taking the standard decorator arguments. This pattern is fundamental for creating configurable decorators.

typescript
function LogMethod(message: string) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.log(`${message}: Calling method ${String(propertyKey)} with args:`, args);
            return originalMethod.apply(this, args);
        };
        return descriptor;
    };
}

class MyService {
    @LogMethod("DEBUG")
    doSomething(arg1: string, arg2: number) {
        console.log("Inside doSomething logic");
        return `Result for ${arg1}, ${arg2}`;
    }
}
const service = new MyService();
service.doSomething("hello", 123);
// Expected console output:
// DEBUG: Calling method doSomething with args: [ 'hello', 123 ]
// Inside doSomething logic

2. Decorator Composition and Execution Order

Decorators can be composed by applying multiple decorators to a single declaration. Understanding their execution order is crucial: multiple decorators applied to a method, property, or parameter are evaluated bottom-up. For class decorators, they are also evaluated bottom-up. For multiple decorators on the same line, they execute from right to left.

typescript
function DecoratorA(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("Applying Decorator A");
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        console.log("Decorator A before call");
        const result = originalMethod.apply(this, args);
        console.log("Decorator A after call");
        return result;
    };
}

function DecoratorB(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("Applying Decorator B");
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        console.log("Decorator B before call");
        const result = originalMethod.apply(this, args);
        console.log("Decorator B after call
");
        return result;
    };
}

class Example {
    @DecoratorA
    @DecoratorB
    greet() {
        console.log("Hello from greet method");
    }
}
new Example().greet();
/* Expected console output:
Applying Decorator B
Applying Decorator A
Decorator A before call
Decorator B before call
Hello from greet method
Decorator B after call
Decorator A after call
*/

3. Method Decorators for Aspect-Oriented Programming (AOP)

Method decorators are ideal for implementing AOP concerns like logging, caching, validation, error handling, or transaction management. They allow you to wrap the original method's logic with cross-cutting functionality without modifying the core business logic, promoting modularity and reusability.

typescript
const cache: Map<string, any> = new Map();

function CacheResult(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
        const cacheKey = `${String(propertyKey)}_${JSON.stringify(args)}`;
        if (cache.has(cacheKey)) {
            console.log(`Cache hit for ${cacheKey}`);
            return cache.get(cacheKey);
        }
        console.log(`Cache miss for ${cacheKey}. Executing method.`);
        const result = originalMethod.apply(this, args);
        cache.set(cacheKey, result);
        return result;
    };
    return descriptor;
}

class DataService {
    @CacheResult
    async fetchData(id: number, type: string) {
        console.log(`Fetching data from external source for id: ${id}, type: ${type}`);
        // Simulate a network request
        return new Promise(resolve => setTimeout(() => resolve({ id, type, data: Math.random() }), 1000));
    }
}

const service = new DataService();
service.fetchData(1, "users").then(data => console.log('First call result:', data));
service.fetchData(1, "users").then(data => console.log('Second call result:', data)); // Should be cached

4. Property Decorators for Metadata Reflection

Property decorators can be used to attach metadata to properties, which can then be read at runtime using reflect-metadata (a polyfill for ECMAScript metadata reflection API) or other mechanisms. This is common in ORMs (e.g., @Column), validation libraries (e.g., @IsNotEmpty), or serialization frameworks (e.g., @Exclude) to define schema or rules without boilerplate code.

typescript
import "reflect-metadata"; // Ensure this is imported once at the entry point

const RequiredMetadataKey = Symbol("Required");

function Required(target: Object, propertyKey: string | symbol) {
    let requiredProps: string[] = Reflect.getMetadata(RequiredMetadataKey, target) || [];
    requiredProps.push(propertyKey as string);
    Reflect.defineMetadata(RequiredMetadataKey, requiredProps, target);
}

function validate(instance: any) {
    let requiredProps: string[] = Reflect.getMetadata(RequiredMetadataKey, instance.constructor.prototype);
    if (requiredProps) {
        for (let prop of requiredProps) {
            if (instance[prop] === undefined || instance[prop] === null) {
                throw new Error(`Validation failed: Property '${prop}' is required.`);
            }
        }
    }
}

class User {
    @Required
    firstName: string;
    lastName: string;

    constructor(firstName: string, lastName: string) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

try {
    const user1 = new User("John", "Doe");
    validate(user1); // Should pass
    console.log("User 1 is valid.");

    const user2 = new User(null as any, "Doe"); // Intentionally passing null for firstName
    validate(user2); // Should throw error
} catch (error: any) {
    console.error(error.message);
}

5. Class Decorators for Dependency Injection or Mixins

Class decorators can modify or replace class constructors. They are powerful for implementing dependency injection containers, applying mixins to extend class functionality, or registering classes with a framework. A class decorator receives only the constructor function of the class.

typescript
const ServiceRegistry = new Map<string, any>();

function Injectable(name: string) {
    return function <T extends { new(...args: any[]): {} }>(constructor: T) {
        ServiceRegistry.set(name, constructor);
        // Optionally, return a new constructor that might handle dependency resolution
        return class extends constructor {
            // Add proxying or dependency resolution logic here if needed
        };
    };
}

@Injectable("LoggerService")
class Logger {
    log(message: string) {
        console.log(`[Logger]: ${message}`);
    }
}

@Injectable("UserService")
class UserService {
    private logger: Logger;
    constructor() {
        // Simple manual resolution for demonstration
        this.logger = new (ServiceRegistry.get("LoggerService"))();
    }
    getUser(id: number) {
        this.logger.log(`Fetching user ${id}`);
        return { id, name: "Alice" };
    }
}

const userService = new (ServiceRegistry.get("UserService"))();
console.log(userService.getUser(1));

6. Parameter Decorators for Argument Injection/Metadata

Parameter decorators are applied to the parameters of a class constructor or method. They receive the target, the method name (or undefined for constructor parameters), and the parameter's ordinal index. They are commonly used to inject dependencies, extract information from request objects in web frameworks, or define metadata for argument validation or serialization.

typescript
import "reflect-metadata";

const InjectParamMetadataKey = Symbol("InjectParam");

function Inject(token: string) {
    return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
        let existingInjections: { index: number, token: string }[] = Reflect.getOwnMetadata(InjectParamMetadataKey, target, propertyKey!) || [];
        existingInjections.push({ index: parameterIndex, token: token });
        Reflect.defineMetadata(InjectParamMetadataKey, existingInjections, target, propertyKey!);
    };
}

class MyService {
    getValue(key: string) {
        return `Value for ${key}`;
    }
}

class MyController {
    constructor(private service: MyService) {}

    execute(@Inject("USER_ID") userId: string, @Inject("SESSION_ID") sessionId: string) {
        console.log(`Executing with userId: ${userId}, sessionId: ${sessionId}`);
        console.log(`Service value: ${this.service.getValue("config.name")}`);
    }
}

// A simple DI-like resolver for demonstration purposes
function resolveMethodArgs(targetInstance: any, methodName: string, argsResolver: (token: string) => any) {
    const injections: { index: number, token: string }[] = Reflect.getOwnMetadata(InjectParamMetadataKey, targetInstance, methodName) || [];
    const resolvedArgs: any[] = [];
    for (const { index, token } of injections) {
        resolvedArgs[index] = argsResolver(token);
    }
    return resolvedArgs;
}

const controller = new MyController(new MyService());
const runtimeArgs = resolveMethodArgs(controller, "execute", (token: string) => {
    if (token === "USER_ID") return "U123";
    if (token === "SESSION_ID") return "SABC";
    return undefined;
});
controller.execute(...runtimeArgs);

7. Common Advanced Use Cases and Patterns

  • AOP (Aspect-Oriented Programming): Implementing cross-cutting concerns like logging, caching, validation, error handling, or transaction management transparently.
  • Dependency Injection (DI): Creating sophisticated DI containers that automatically register services and inject dependencies into constructors or methods.
  • Metadata Reflection: Defining schema for ORMs, serialization, or custom type information, often used in conjunction with reflect-metadata.
  • Mixins/Composition: Dynamically extending class functionality at declaration time, promoting code reuse without traditional inheritance.
  • Framework Integration: Marking components (e.g., @Component, @Controller), configuring routes, or defining lifecycle hooks within frameworks like Angular or NestJS.
  • Runtime Code Generation/Modification: Altering class behavior, adding properties, or replacing methods dynamically at load time.

Conclusion

Advanced decorator patterns unlock powerful meta-programming capabilities in TypeScript, allowing for clean separation of concerns, improved code reusability, and dynamic modification of code. While highly effective, they should be used judiciously, as overuse can sometimes lead to implicit behavior that is harder to trace. Understanding the different decorator types, their execution order, and the reflect-metadata API is key to leveraging them effectively in complex applications and frameworks.