What Are TypeScript Decorators
TypeScript decorators represent one of the language's most powerful yet frequently underutilized features. While they have existed as an experimental feature for several years, the evolving ECMAScript decorator proposal combined with TypeScript's robust implementation makes decorators invaluable for enterprise application development. If you have worked extensively with TypeScript, you have likely encountered decorators in popular frameworks like Angular or NestJS. This guide dives deep into creating your own decorators and understanding their full potential for clean, maintainable code.
Decorators fundamentally enable you to modify classes, methods, properties, and parameters at design time, providing a clean mechanism for adding metadata, changing behavior, or implementing cross-cutting concerns without cluttering your core business logic. The decorator syntax using the @ symbol is simply syntactic sugar for function calls, where each decorator wraps and transforms the target it decorates. Understanding this foundational concept opens the door to powerful patterns that can significantly improve your codebase's organization and maintainability.
A decorator is a special kind of declaration that can be attached to:
- Class declarations
- Method declarations
- Class properties
- Method parameters
Decorators use the form @expression where expression must evaluate to a function that will be called at runtime with information about the decorated declaration. When you apply multiple decorators to a declaration, the evaluation proceeds from bottom to top, while the execution proceeds from top to bottom, which becomes important when decorators have dependencies or side effects.
For example, when you write @Component on a class in Angular, TypeScript calls the Component function and passes the class constructor as an argument. This function then returns a modified constructor or a completely new class definition. The @ symbol is syntactic sugar that translates directly into function calls, meaning that @MyDecorator on a class is equivalent to MyDecorator(class MyClass {}).
This pattern aligns with the TypeScript decorators specification and enables powerful cross-cutting concerns in your web development projects. For teams building complex applications, mastering decorators is essential for creating maintainable enterprise software solutions.
Types of TypeScript Decorators
TypeScript supports four primary categories of decorators, each designed for specific targets within your class definitions. Understanding these categories and their signatures is essential for creating effective decorators that serve their intended purpose. Each decorator type receives different arguments and can return different values, making them suitable for different use cases ranging from class transformation to method wrapping to property metadata collection.
1. Class Decorators
Class decorators apply to the constructor function of a class and can return a new constructor function or modify the existing constructor. They receive a single argument: the class constructor being decorated. The decorator function can return a new class that extends, wraps, or completely replaces the original constructor. This capability makes class decorators ideal for implementing patterns like singleton, adding metadata, modifying class behavior, or extending functionality across many classes.
Use cases: Singleton pattern, adding metadata, modifying class behavior, extending functionality, dependency injection containers.
2. Method Decorators
Method decorators receive three arguments: the target object (prototype for instance methods, constructor for static methods), the property name, and the property descriptor. By modifying the descriptor's value property, you can wrap the original method with additional behavior such as logging, caching, validation, or timing code. Method decorators are among the most commonly used decorator types because they enable powerful cross-cutting concerns without modifying the original method implementation.
Use cases: Performance benchmarking, memoization, access control, logging, API rate limiting.
3. Property Decorators
Property decorators apply to class properties rather than methods, and they receive two arguments: the target object and the property name. Property decorators are primarily used for collecting metadata about properties, setting up validation rules, or preparing infrastructure for other decorators like method-level validators that need to inspect property metadata.
Use cases: Validation rules, field metadata, ORM mappings, form validation systems.
4. Parameter Decorators
Parameter decorators apply to individual parameters within method declarations and receive three arguments: the target object, the method name, and the index of the parameter being decorated. Combined with metadata reflection, parameter decorators enable sophisticated dependency injection systems where the container can determine the correct type and token for each parameter.
Use cases: Dependency injection, parameter validation, service resolution, microservices communication.
Class Decorators: Practical Example
Class decorators can implement patterns like singleton, which ensures only one instance exists throughout your application. This pattern proves particularly valuable for database connections, configuration managers, and other services where multiple instances would cause problems:
function Singleton<T extends new (...args: any[]) => any>(constructor: T) {
let instance: T | null = null;
return class extends constructor {
constructor(...args: any[]) {
if (instance) {
return instance;
}
super(...args);
instance = this as any;
return this;
}
};
}
@Singleton
class DatabaseConnection {
constructor(private connectionString: string) {}
connect() {
console.log(`Connecting to ${this.connectionString}`);
}
}
const db1 = new DatabaseConnection('mongodb://localhost');
const db2 = new DatabaseConnection('postgresql://localhost');
console.log(db1 === db2); // true - same instance
Key patterns demonstrated:
- Wrapping the original constructor while preserving the prototype chain
- Singleton instance management through closure
- Preserving instanceof checks for proper type inference
This approach is widely used in enterprise software development where shared state and centralized services are common requirements. Understanding these patterns is crucial for teams building scalable cloud-native applications.
Method Decorators: Benchmarking and Caching
Method decorators wrap functions to add behavior like timing measurements or memoization, which are essential patterns for building high-performance applications.
Benchmark Decorator
function Benchmark(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = function(...args: any[]) {
const start = performance.now();
const result = method.apply(this, args);
const end = performance.now();
console.log(`${propertyName} executed in ${(end - start).toFixed(3)}ms`);
return result;
};
return descriptor;
}
Memoization Decorator
function Memoize(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
const cache = new Map();
descriptor.value = function(...args: any[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`Cache hit for ${propertyName}`);
return cache.get(key);
}
const result = method.apply(this, args);
cache.set(key, result);
return result;
};
return descriptor;
}
Combine multiple decorators:
class MathService {
@Benchmark
@Memoize
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
When decorators are stacked, the execution flows through the decorators in a specific order. The Benchmark decorator wraps the original method first, then Memoize wraps the benchmarked method. Understanding this composition is essential for debugging decorated methods and optimizing API performance. These caching patterns are particularly valuable for high-performance web applications that handle compute-intensive operations.
Decorator Factories for Configuration
Decorator factories enable configurable decorators by returning a decorator function, making them flexible and reusable across different contexts.
Retry Decorator
function Retry(maxAttempts: number = 3, delay: number = 1000) {
return function(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = async function(...args: any[]) {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await method.apply(this, args);
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw new Error(`Method ${propertyName} failed after ${maxAttempts} attempts`);
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
};
return descriptor;
};
}
Rate Limit Decorator
function RateLimit(requestsPerMinute: number) {
const requestTimes: number[] = [];
return function(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = function(...args: any[]) {
const now = Date.now();
const oneMinuteAgo = now - 60000;
while (requestTimes.length > 0 && requestTimes[0] < oneMinuteAgo) {
requestTimes.shift();
}
if (requestTimes.length >= requestsPerMinute) {
throw new Error(`Rate limit exceeded`);
}
requestTimes.push(now);
return method.apply(this, args);
};
return descriptor;
};
}
These patterns are essential for building resilient microservices and APIs that can handle transient failures gracefully. Implementing proper retry and rate limiting is critical for production-grade systems that require high availability.
Property and Parameter Decorators
Property Decorators for Validation
function MinLength(length: number) {
return function(target: any, propertyName: string) {
Reflect.defineMetadata('minLength', length, target, propertyName);
};
}
function Email(target: any, propertyName: string) {
Reflect.defineMetadata('isEmail', true, target, propertyName);
}
function Validate(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = function(...args: any[]) {
for (const prop in this) {
const minLength = Reflect.getMetadata('minLength', this, prop);
const isEmail = Reflect.getMetadata('isEmail', this, prop);
if (minLength && this[prop].length < minLength) {
throw new Error(`${prop} must be at least ${minLength} characters`);
}
if (isEmail && !this[prop].includes('@')) {
throw new Error(`${prop} must be a valid email`);
}
}
return method.apply(this, args);
};
}
Parameter Decorators for Dependency Injection
const injectionTokens = new Map();
function Inject(token: string) {
return function(target: any, propertyName: string | undefined, parameterIndex: number) {
const existingTokens = injectionTokens.get(target) || [];
existingTokens[parameterIndex] = token;
injectionTokens.set(target, existingTokens);
};
}
@Injectable
class OrderService {
constructor(
@Inject('UserRepository') private userRepo: any,
@Inject('EmailService') private emailService: any
) {}
}
This dependency injection implementation demonstrates the core pattern used by major frameworks like NestJS. The parameter decorator stores the injection token, while the class decorator intercepts construction to resolve and inject dependencies from a service locator. These patterns are foundational for building testable component libraries and modular enterprise applications.
Best Practices for Using Decorators
When to Use Decorators
Use decorators for:
- Cross-cutting concerns (logging, validation, caching)
- Patterns applied across many classes
- Dependency injection infrastructure
- Code that benefits from declarative composition
- Building reusable software components
Avoid decorators for:
- Simple, one-off modifications
- Performance-critical hot paths (profile first)
- Cases where indirection hurts readability
Performance Considerations
Decorators add minimal overhead through additional function calls. For most applications, this is negligible. However, in performance-critical paths:
- Profile decorated vs. undecorated methods using benchmarking decorators
- Consider compile-time alternatives for hot paths
- Use caching decorators strategically to improve rather than degrade performance
Debugging Decorated Code
- Use named wrapper functions to preserve stack trace readability
- Enable source maps for proper debugging
- Add meaningful logging within decorators
- Document decorator behavior clearly
Composition Order
When using multiple decorators, they execute bottom-to-top but compose top-to-bottom:
class ApiService {
@Retry(3)
@RateLimit(10)
@Benchmark
async fetchData() { /* ... */ }
}
Execution flows: Benchmark → RateLimit → Retry → Original method. When the call completes, the results propagate back through the decorators in reverse order. These composition patterns are essential knowledge for teams building complex web applications with multiple cross-cutting concerns.
Clean Separation of Concerns
Isolate cross-cutting concerns like logging, validation, and caching from business logic
Reusable Code Patterns
Define patterns once and apply them across multiple classes and methods
Declarative Style
Express intent clearly at the point of declaration using readable syntax
Framework Integration
Power Angular, NestJS, and other enterprise frameworks with proven patterns
Frequently Asked Questions
Sources
- TypeScript Official Documentation - Decorators - Official documentation providing the authoritative reference on decorator syntax, types, and configuration options
- LogRocket Blog - A practical guide to TypeScript decorators - Comprehensive practical guide covering class decorators, method decorators, property decorators, and real-world examples
- Ana's Blog - A Practical Guide to TypeScript Decorators - Modern guide covering decorator factories, parameter decorators, and dependency injection patterns