Skip to main content

Chapter 6: Decorators

Authored by syscook.dev

What are Decorators in TypeScript?

Decorators are a special kind of declaration that can be attached to classes, methods, properties, or parameters. They provide a way to add metadata and modify behavior at design time, enabling powerful patterns like dependency injection, logging, validation, and more.

Key Concepts:

  • Class Decorators: Applied to class declarations
  • Method Decorators: Applied to method declarations
  • Property Decorators: Applied to property declarations
  • Parameter Decorators: Applied to parameter declarations
  • Decorator Factories: Functions that return decorator functions
  • Metadata Reflection: Accessing decorator metadata at runtime

Why Use Decorators?

1. Cross-Cutting Concerns

Decorators help separate cross-cutting concerns from business logic.

// Without decorators - mixed concerns
class UserService {
async getUser(id: number) {
console.log(`Getting user ${id}`); // Logging mixed with business logic
const startTime = Date.now();

try {
const user = await this.fetchUser(id);
console.log(`User ${id} retrieved in ${Date.now() - startTime}ms`);
return user;
} catch (error) {
console.error(`Error getting user ${id}:`, error);
throw error;
}
}
}

// With decorators - separated concerns
function log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = async function (...args: any[]) {
console.log(`Calling ${propertyName} with args:`, args);
const startTime = Date.now();

try {
const result = await method.apply(this, args);
console.log(`${propertyName} completed in ${Date.now() - startTime}ms`);
return result;
} catch (error) {
console.error(`Error in ${propertyName}:`, error);
throw error;
}
};
}

class UserService {
@log
async getUser(id: number) {
return await this.fetchUser(id);
}
}

2. Framework Integration

Decorators are essential for frameworks like Angular, NestJS, and TypeORM.

// Angular-style decorators
@Component({
selector: 'app-user',
template: '<div>{{user.name}}</div>'
})
export class UserComponent {
@Input() user: User;
@Output() userChange = new EventEmitter<User>();

@HostListener('click')
onClick() {
this.userChange.emit(this.user);
}
}

// NestJS-style decorators
@Controller('users')
export class UserController {
constructor(private userService: UserService) {}

@Get(':id')
@UseGuards(AuthGuard)
async getUser(@Param('id') id: string) {
return this.userService.getUser(id);
}
}

How to Use Decorators?

1. Class Decorators

Basic Class Decorator

// Simple class decorator
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}

@sealed
class User {
name: string;
email: string;

constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
}

// Class decorator with parameters (decorator factory)
function Component(config: { selector: string; template: string }) {
return function (constructor: Function) {
constructor.prototype.selector = config.selector;
constructor.prototype.template = config.template;
};
}

@Component({
selector: 'app-user',
template: '<div>User Component</div>'
})
class UserComponent {
// Component logic
}

console.log("User class sealed:", Object.isSealed(User));
console.log("UserComponent selector:", (UserComponent as any).prototype.selector);

Output:

User class sealed: true
UserComponent selector: app-user

Advanced Class Decorator

// Class decorator that adds methods
function withTimestamps<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
createdAt = new Date();
updatedAt = new Date();

updateTimestamp() {
this.updatedAt = new Date();
}
};
}

@withTimestamps
class Product {
name: string;
price: number;

constructor(name: string, price: number) {
this.name = name;
this.price = price;
}
}

const product = new Product("Laptop", 999);
console.log("Product created at:", product.createdAt);
console.log("Product updated at:", product.updatedAt);

product.updateTimestamp();
console.log("Product updated at (after update):", product.updatedAt);

Output:

Product created at: 2024-01-15T10:30:00.000Z
Product updated at: 2024-01-15T10:30:00.000Z
Product updated at (after update): 2024-01-15T10:30:00.000Z

2. Method Decorators

Basic Method Decorator

// Method decorator for logging
function log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyName} with arguments:`, args);
const result = method.apply(this, args);
console.log(`${propertyName} returned:`, result);
return result;
};
}

// Method decorator for validation
function validate(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = function (...args: any[]) {
// Simple validation example
if (args.some(arg => arg === null || arg === undefined)) {
throw new Error(`Invalid arguments for ${propertyName}`);
}
return method.apply(this, args);
};
}

class Calculator {
@log
@validate
add(a: number, b: number): number {
return a + b;
}

@log
multiply(a: number, b: number): number {
return a * b;
}
}

const calc = new Calculator();
console.log("Result:", calc.add(5, 3));
console.log("Result:", calc.multiply(4, 7));

Output:

Calling add with arguments: [5, 3]
add returned: 8
Result: 8
Calling multiply with arguments: [4, 7]
multiply returned: 28
Result: 28

Method Decorator with Parameters

// Decorator factory for method timing
function timing(threshold: number = 100) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = async function (...args: any[]) {
const startTime = Date.now();
const result = await method.apply(this, args);
const duration = Date.now() - startTime;

if (duration > threshold) {
console.warn(`Slow method ${propertyName}: ${duration}ms (threshold: ${threshold}ms)`);
} else {
console.log(`Method ${propertyName} completed in ${duration}ms`);
}

return result;
};
};
}

class DataService {
@timing(50)
async fetchData(): Promise<any> {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 30));
return { data: "Sample data" };
}

@timing(50)
async slowOperation(): Promise<any> {
// Simulate slow operation
await new Promise(resolve => setTimeout(resolve, 100));
return { data: "Slow data" };
}
}

const dataService = new DataService();
dataService.fetchData().then(result => {
console.log("Fast operation result:", result);
});

dataService.slowOperation().then(result => {
console.log("Slow operation result:", result);
});

Output:

Method fetchData completed in 30ms
Fast operation result: { data: 'Sample data' }
Slow method slowOperation: 100ms (threshold: 50ms)
Slow operation result: { data: 'Slow data' }

3. Property Decorators

Basic Property Decorator

// Property decorator for formatting
function format(formatFn: (value: any) => any) {
return function (target: any, propertyName: string) {
let value = target[propertyName];

const getter = function () {
return value;
};

const setter = function (newValue: any) {
value = formatFn(newValue);
};

Object.defineProperty(target, propertyName, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
};
}

// Property decorator for validation
function required(target: any, propertyName: string) {
const validators = target.constructor._validators || (target.constructor._validators = []);
validators.push({
property: propertyName,
validator: (value: any) => value != null && value !== ''
});
}

class User {
@required
@format((value: string) => value?.trim().toLowerCase())
email: string;

@format((value: string) => value?.trim())
name: string;

constructor(email: string, name: string) {
this.email = email;
this.name = name;
}

validate(): boolean {
const validators = (this.constructor as any)._validators || [];
return validators.every((validator: any) => validator.validator(this[validator.property]));
}
}

const user = new User(" [email protected] ", " John Doe ");
console.log("Email:", user.email);
console.log("Name:", user.name);
console.log("Valid:", user.validate());

Output:

Email: [email protected]
Name: John Doe
Valid: true

4. Parameter Decorators

Basic Parameter Decorator

// Parameter decorator for validation
function validateParam(target: any, propertyName: string, parameterIndex: number) {
const existingValidators = target.constructor._paramValidators || (target.constructor._paramValidators = []);
existingValidators.push({
method: propertyName,
parameterIndex,
validator: (value: any) => value != null && value !== ''
});
}

// Parameter decorator for logging
function logParam(target: any, propertyName: string, parameterIndex: number) {
const existingLoggers = target.constructor._paramLoggers || (target.constructor._paramLoggers = []);
existingLoggers.push({
method: propertyName,
parameterIndex
});
}

class UserService {
getUser(@validateParam @logParam id: number, @validateParam name: string) {
console.log(`Getting user with ID: ${id}, Name: ${name}`);
return { id, name };
}
}

// Usage
const userService = new UserService();
const user = userService.getUser(1, "John");
console.log("User:", user);

Output:

Getting user with ID: 1, Name: John
User: { id: 1, name: 'John' }

5. Decorator Composition

// Multiple decorators on a single target
function readonly(target: any, propertyName: string, descriptor: PropertyDescriptor) {
descriptor.writable = false;
}

function enumerable(value: boolean) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
descriptor.enumerable = value;
};
}

class Example {
@readonly
@enumerable(false)
secret: string = "secret value";

@enumerable(true)
public: string = "public value";
}

const example = new Example();
console.log("Object keys:", Object.keys(example));
console.log("Secret:", example.secret);

// example.secret = "new value"; // Error: Cannot assign to read only property

Output:

Object keys: ['public']
Secret: secret value

Practical Examples

1. Dependency Injection System

// Simple dependency injection system using decorators
const dependencies = new Map();

function Injectable(target: any) {
dependencies.set(target.name, target);
return target;
}

function Inject(token: string) {
return function (target: any, propertyName: string, parameterIndex: number) {
const existingTokens = target.constructor._injectTokens || (target.constructor._injectTokens = []);
existingTokens[parameterIndex] = token;
};
}

class Container {
private instances = new Map();

resolve<T>(token: string): T {
if (this.instances.has(token)) {
return this.instances.get(token);
}

const constructor = dependencies.get(token);
if (!constructor) {
throw new Error(`No dependency found for token: ${token}`);
}

const instance = new constructor();
this.instances.set(token, instance);
return instance;
}
}

// Usage
@Injectable
class DatabaseService {
connect() {
console.log("Database connected");
}
}

@Injectable
class UserRepository {
constructor(@Inject("DatabaseService") private db: DatabaseService) {}

findUser(id: number) {
this.db.connect();
return { id, name: "John" };
}
}

@Injectable
class UserService {
constructor(@Inject("UserRepository") private userRepo: UserRepository) {}

getUser(id: number) {
return this.userRepo.findUser(id);
}
}

// Manual dependency injection (in real frameworks, this is automatic)
const container = new Container();
const userService = container.resolve<UserService>("UserService");
const user = userService.getUser(1);
console.log("User:", user);

Output:

Database connected
User: { id: 1, name: 'John' }

2. API Route Decorators

// API route decorators
const routes: any[] = [];

function Controller(basePath: string) {
return function (target: any) {
target.prototype.basePath = basePath;
};
}

function Get(path: string) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
routes.push({
method: 'GET',
path: target.prototype.basePath + path,
handler: descriptor.value,
controller: target.constructor.name
});
};
}

function Post(path: string) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
routes.push({
method: 'POST',
path: target.prototype.basePath + path,
handler: descriptor.value,
controller: target.constructor.name
});
};
}

function Body(target: any, propertyName: string, parameterIndex: number) {
const existingParams = target.constructor._bodyParams || (target.constructor._bodyParams = []);
existingParams[parameterIndex] = 'body';
}

@Controller('/api/users')
class UserController {
@Get('/')
getAllUsers() {
return { users: [{ id: 1, name: "John" }] };
}

@Get('/:id')
getUserById(id: number) {
return { id, name: "John" };
}

@Post('/')
createUser(@Body userData: any) {
return { id: 2, ...userData };
}
}

// Display registered routes
console.log("Registered routes:");
routes.forEach(route => {
console.log(`${route.method} ${route.path} -> ${route.controller}.${route.handler.name}`);
});

Output:

Registered routes:
GET /api/users/ -> UserController.getAllUsers
GET /api/users/:id -> UserController.getUserById
POST /api/users/ -> UserController.createUser

Best Practices

1. Use Decorators for Cross-Cutting Concerns

// Good: Use decorators for logging, validation, caching
@Log
@Validate
@Cache(300)
async getUser(id: number) {
return this.userRepository.findById(id);
}

2. Keep Decorators Simple and Focused

// Good: Single responsibility decorators
function Log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
// Only handles logging
}

function Validate(target: any, propertyName: string, descriptor: PropertyDescriptor) {
// Only handles validation
}

3. Use Decorator Factories for Configuration

// Good: Configurable decorators
function Cache(ttl: number) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
// Cache implementation with TTL
};
}

4. Document Decorator Behavior

/**
* Logs method calls with timing information
* @param threshold - Warning threshold in milliseconds
*/
function timing(threshold: number = 100) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
// Implementation
};
}

Common Pitfalls and Solutions

1. Decorator Execution Order

// Decorators execute from bottom to top
class Example {
@decoratorA
@decoratorB
method() {}
}

// Execution order: decoratorB, then decoratorA

2. Metadata Reflection API

// Enable experimental decorators and metadata in tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

// Install reflect-metadata
// npm install reflect-metadata

3. Decorator Context

// Be careful with 'this' context in decorators
function log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;

descriptor.value = function (...args: any[]) {
// Use function() to preserve 'this' context
return method.apply(this, args);
};
}

Conclusion

Decorators in TypeScript provide powerful tools for adding metadata and modifying behavior at design time. By understanding:

  • What decorators are and their types
  • Why they're useful for cross-cutting concerns and framework integration
  • How to create and use class, method, property, and parameter decorators

You can create sophisticated applications with clean separation of concerns. Decorators enable powerful patterns like dependency injection, aspect-oriented programming, and framework integration while keeping your business logic clean and focused.

Next Steps

  • Practice creating custom decorators
  • Explore framework-specific decorator patterns
  • Learn about metadata reflection API
  • Move on to Chapter 7: Utility Types

This tutorial is part of the TypeScript Mastery series by syscook.dev