TypeScript Decorators
Decorators in TypeScript provide a powerful way to add extra functionality to your classes and their members without modifying the actual code. These special declarations act like annotations that can observe, modify, or replace class definitions and their properties. While decorators are still an experimental feature in TypeScript, they've become incredibly popular in frameworks like Angular and NestJS for simplifying complex patterns and reducing boilerplate code.
What Are Decorators and How Do They Work?
Decorators are functions that receive special parameters from TypeScript and can transform the decorated item. They use the @expression
syntax, where the expression evaluates to a function that gets called at runtime with information about the decorated declaration.
Enabling Decorators in Your Project
First, make sure your tsconfig.json has these compiler options:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Basic Decorator Structure
Here's what a simple decorator function looks like:
function simpleDecorator(target: any) {
console.log('Decorator applied to:', target);
}
@simpleDecorator
class MyClass {}
When you run this code, you'll see "Decorator applied to: [Function: MyClass]" in your console. The decorator receives the class constructor as its parameter.
Different Types of Decorators
TypeScript supports several decorator types:
- 1Class Decorators - Applied to class constructors
- 2Method Decorators - Applied to class methods
- 3Property Decorators - Applied to class properties
- 4Accessor Decorators - Applied to getters/setters
- 5Parameter Decorators - Applied to method parameters
Each type receives different parameters giving context about what's being decorated.
Using Decorators in Classes and Methods
Class Decorators
These can modify or replace class definitions:
function addTimestamp(target: Function) {
target.prototype.createdAt = new Date();
}
@addTimestamp
class Document {
content: string;
constructor(content: string) {
this.content = content;
}
}
const doc = new Document("Hello");
console.log((doc as any).createdAt); // Shows current date
Method Decorators
Great for adding logging, validation, or other cross-cutting concerns:
function logExecution(target: any, key: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Calling ${key} with args:`, args);
const result = originalMethod.apply(this, args);
console.log(`Method ${key} returned:`, result);
return result;
};
return descriptor;
}
class Calculator {
@logExecution
add(a: number, b: number) {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3); // Logs method call and result
Property Decorators
Useful for tracking metadata or adding validation:
function formatDate(format: string) {
return function(target: any, key: string) {
let value = target[key];
const getter = () => value;
const setter = (newVal: Date) => {
value = newVal.toLocaleDateString('en-US', { dateStyle: format });
};
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
};
}
class Report {
@formatDate('full')
generatedAt: Date;
constructor() {
this.generatedAt = new Date();
}
}
const report = new Report();
console.log(report.generatedAt); // "Monday, January 1, 2023"
Parameter Decorators
Mostly used for metadata reflection:
function validateParam(min: number, max: number) {
return function(target: any, key: string, index: number) {
// Store validation rules in metadata
const existingValidations = Reflect.getMetadata('validations', target, key) || [];
existingValidations.push({ index, min, max });
Reflect.defineMetadata('validations', existingValidations, target, key);
};
}
class MathOperations {
squareRoot(
@validateParam(0, 1000)
num: number
) {
return Math.sqrt(num);
}
}
Practical Use Cases for Decorators in TypeScript
1. API Route Handling (NestJS/Angular style)
function Controller(prefix: string) {
return function(target: Function) {
target.prototype.prefix = prefix;
};
}
function Get(path: string) {
return function(target: any, key: string, descriptor: PropertyDescriptor) {
const routes = Reflect.getMetadata('routes', target.constructor) || [];
routes.push({ method: 'get', path, handler: key });
Reflect.defineMetadata('routes', routes, target.constructor);
};
}
@Controller('/users')
class UserController {
@Get('/')
getAll() {
return ['user1', 'user2'];
}
@Get('/:id')
getById(id: string) {
return { id, name: 'Test User' };
}
}
2. Database Entity Modeling (TypeORM style)
function Entity(tableName: string) {
return function(target: Function) {
Reflect.defineMetadata('tableName', tableName, target);
};
}
function Column(options: { type: string, nullable?: boolean }) {
return function(target: any, key: string) {
const columns = Reflect.getMetadata('columns', target.constructor) || [];
columns.push({ name: key, ...options });
Reflect.defineMetadata('columns', columns, target.constructor);
};
}
@Entity('users')
class User {
@Column({ type: 'uuid', nullable: false })
id: string;
@Column({ type: 'varchar' })
name: string;
@Column({ type: 'varchar', nullable: true })
email?: string;
}
3. Dependency Injection
const services = new Map();
function Injectable() {
return function(target: Function) {
services.set(target.name, new target());
};
}
function Inject(serviceName: string) {
return function(target: any, key: string) {
Object.defineProperty(target, key, {
get: () => services.get(serviceName)
});
};
}
@Injectable()
class DatabaseService {
query() {
return 'Data from database';
}
}
class UserRepository {
@Inject('DatabaseService')
db: DatabaseService;
getUsers() {
return this.db.query();
}
}
4. Performance Measurement
function measureTime(target: any, key: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
const start = performance.now();
const result = await originalMethod.apply(this, args);
const end = performance.now();
console.log(`Method ${key} took ${(end - start).toFixed(2)}ms`);
return result;
};
return descriptor;
}
class DataProcessor {
@measureTime
async processLargeData() {
// Simulate long-running task
await new Promise(resolve => setTimeout(resolve, 1000));
return 'Processing complete';
}
}
5. Form Validation
function Required(target: any, key: string) {
const requiredFields = Reflect.getMetadata('required', target.constructor) || [];
if (!requiredFields.includes(key)) {
requiredFields.push(key);
}
Reflect.defineMetadata('required', requiredFields, target.constructor);
}
class UserForm {
@Required
username: string;
@Required
password: string;
bio?: string;
validate() {
const requiredFields = Reflect.getMetadata('required', this.constructor) || [];
for (const field of requiredFields) {
if (!this[field]) {
throw new Error(`${field} is required`);
}
}
}
}
Decorators provide an elegant way to add reusable functionality across your TypeScript application. While they're powerful, remember to use them judiciously - overusing decorators can make code harder to understand. The best approach is to reserve them for cross-cutting concerns that would otherwise require repetitive boilerplate code. When used properly, decorators can significantly clean up your codebase while adding maintainable functionality.