Learn TypeScript: Decorators

You may want to read this article in Bahasa Indonesia version: Mengenal TypeScript: Decorators
Decorators are an experimental feature in TypeScript that allow us to add annotations and metadata to classes, methods, properties, and parameters. This article will explain the concept, syntax, and practical use of Decorators in TypeScript.
Keep in mind that since decorators are still an experimental feature, their syntax and behavior may change in the future. Therefore, make sure to always check the latest TypeScript documentation for the latest information on decorators. You can check the status of decorators here: https://github.com/tc39/proposal-decorators.
What are Decorators?
Decorators are a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the syntax @expression
, where expression
must evaluate to a function that will be called at runtime with information about the decorated declaration.
Note: Decorators are still an experimental feature in TypeScript. To use them, you need to enable the
experimentalDecorators
flag in yourtsconfig.json
file:{ "compilerOptions": { "experimentalDecorators": true } }
Types of Decorators
TypeScript supports several types of decorators:
- Class Decorators
- Method Decorators
- Property Decorators
- Parameter Decorators
- Accessor Decorators
Let’s discuss each of these types.
Class Decorators
Class decorators are applied to class declarations and can be used to observe, modify, or replace a class definition.
/** Class decorator function */
function Logger(target: Function) {
console.log(`Class ${target.name} has been created`);
}
/** Using class decorator */
@Logger
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
const person = new Person("John"); // Output: "Class Person has been created"
Class decorators can also return a new class that extends the original class:
/** Class decorator that returns a new class */
function WithTimestamp<T extends { new (...args: any[]): {} }>(target: T) {
return class extends target {
createdAt = new Date();
getTimestamp() {
return this.createdAt;
}
};
}
/** Using class decorator */
@WithTimestamp
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
const user = new User("Alice");
console.log(user.name); // "Alice"
console.log((user as any).getTimestamp()); // Displays current timestamp
Method Decorators
Method decorators are applied to method declarations and can be used to observe, modify, or replace a method definition.
/** Method decorator function */
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(
`Calling ${propertyKey} with arguments: ${JSON.stringify(args)}`
);
const result = originalMethod.apply(this, args);
console.log(`Result from ${propertyKey}: ${result}`);
return result;
};
return descriptor;
}
class Calculator {
/** Using method decorator */
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(5, 3);
// Output:
// "Calling add with arguments: [5,3]"
// "Result from add: 8"
Property Decorators
Property decorators are applied to property declarations and can be used to observe or modify a property.
/** Property decorator function */
function Format(formatString: string) {
return function (target: any, propertyKey: string) {
let value: any;
const getter = function () {
return value;
};
const setter = function (newValue: any) {
value = formatString.replace("%s", newValue);
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
class Greeting {
/** Using property decorator */
@Format("Hello, %s!")
name: string;
}
const greeting = new Greeting();
greeting.name = "World";
console.log(greeting.name); // "Hello, World!"
Parameter Decorators
Parameter decorators are applied to parameter declarations of methods or constructors and can be used to observe parameters.
/** Parameter decorator function */
function Required(target: any, propertyKey: string, parameterIndex: number) {
const requiredParams: number[] =
Reflect.getMetadata("required", target, propertyKey) || [];
requiredParams.push(parameterIndex);
Reflect.defineMetadata("required", requiredParams, target, propertyKey);
}
/** Method decorator for validation */
function Validate(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const requiredParams: number[] =
Reflect.getMetadata("required", target, propertyKey) || [];
for (const index of requiredParams) {
if (args[index] === undefined || args[index] === null) {
throw new Error(`Parameter at index ${index} is required`);
}
}
return originalMethod.apply(this, args);
};
return descriptor;
}
class UserService {
/** Using parameter decorator and method decorator */
@Validate
createUser(name: string, @Required email: string, age?: number) {
return { name, email, age };
}
}
const userService = new UserService();
userService.createUser("John", "john@example.com", 30); // OK
// userService.createUser("Alice", null); // Error: Parameter at index 1 is required
Note: The example above uses reflect-metadata, which needs to be installed and imported:
npm install reflect-metadata
import "reflect-metadata";
Accessor Decorators
Accessor decorators are applied to the property accessor declarations (getter/setter) and can be used to observe, modify, or replace an accessor’s definition.
/** Accessor decorator function */
function Enumerable(value: boolean) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
descriptor.enumerable = value;
return descriptor;
};
}
class Person {
private _name: string;
constructor(name: string) {
this._name = name;
}
/** Using accessor decorator */
@Enumerable(false)
get name(): string {
return this._name;
}
set name(value: string) {
this._name = value;
}
}
const person = new Person("John");
console.log(person.name); // "John"
console.log(Object.keys(person)); // [] (name is not included because enumerable: false)
Decorator Factories
A decorator factory is a function that returns a decorator. This allows us to customize how a decorator is applied to a declaration.
/** Decorator factory */
function Log(prefix: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`${prefix} Calling ${propertyKey}`);
const result = originalMethod.apply(this, args);
console.log(`${prefix} Result: ${result}`);
return result;
};
return descriptor;
};
}
class MathUtils {
/** Using decorator factory */
@Log("[DEBUG]")
square(n: number): number {
return n * n;
}
@Log("[INFO]")
cube(n: number): number {
return n * n * n;
}
}
const math = new MathUtils();
math.square(3); // "[DEBUG] Calling square", "[DEBUG] Result: 9"
math.cube(3); // "[INFO] Calling cube", "[INFO] Result: 27"
Decorator Evaluation Order
When multiple decorators are applied to a single declaration, their evaluation follows these rules:
- The decorator expressions are evaluated from top to bottom.
- The results of the evaluations are called as functions from bottom to top.
/** Decorator factories */
function First() {
console.log("First decorator factory");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log("First decorator called");
};
}
function Second() {
console.log("Second decorator factory");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log("Second decorator called");
};
}
class Example {
/** Using multiple decorators */
@First()
@Second()
method() {}
}
// Output:
// "First decorator factory"
// "Second decorator factory"
// "Second decorator called"
// "First decorator called"
Practical Use Cases
Logging and Profiling
/** Method decorator for logging and profiling */
function Profile(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey}`);
const start = performance.now();
const result = originalMethod.apply(this, args);
const end = performance.now();
console.log(`${propertyKey} completed in ${end - start} ms`);
return result;
};
return descriptor;
}
class DataService {
/** Using profiling decorator */
@Profile
fetchData() {
// Simulating a time-consuming operation
const startTime = Date.now();
while (Date.now() - startTime < 1000) {
// Waiting for 1 second
}
return { data: "Some data" };
}
}
const service = new DataService();
service.fetchData();
// Output:
// "Calling fetchData"
// "fetchData completed in 1005.67 ms"
Dependency Injection
/** Decorators for dependency injection */
const services: Map<string, any> = new Map();
function Service(name: string) {
return function <T extends { new (...args: any[]): {} }>(constructor: T) {
services.set(name, new constructor());
return constructor;
};
}
function Inject(serviceName: string) {
return function (target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get: () => services.get(serviceName),
enumerable: true,
configurable: true,
});
};
}
/** Service implementation */
@Service("loggerService")
class LoggerService {
log(message: string) {
console.log(`[LOG]: ${message}`);
}
}
@Service("userService")
class UserService {
getUser(id: number) {
return { id, name: "John" };
}
}
/** Client class using injected services */
class AppComponent {
@Inject("loggerService")
private logger: LoggerService;
@Inject("userService")
private userService: UserService;
run() {
this.logger.log("Application started");
const user = this.userService.getUser(1);
this.logger.log(`User loaded: ${user.name}`);
}
}
const app = new AppComponent();
app.run();
// Output:
// "[LOG]: Application started"
// "[LOG]: User loaded: John"
Validation
/** Decorators for validation */
function Min(limit: number) {
return function (target: any, propertyKey: string) {
let value: any;
const getter = function () {
return value;
};
const setter = function (newValue: any) {
if (newValue < limit) {
throw new Error(`Value cannot be less than ${limit}`);
}
value = newValue;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
function Max(limit: number) {
return function (target: any, propertyKey: string) {
let value: any;
const getter = function () {
return value;
};
const setter = function (newValue: any) {
if (newValue > limit) {
throw new Error(`Value cannot be greater than ${limit}`);
}
value = newValue;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
class Product {
@Min(0)
@Max(1000)
price: number;
constructor(price: number) {
this.price = price;
}
}
const validProduct = new Product(100); // OK
// const invalidProduct1 = new Product(-10); // Error: Value cannot be less than 0
// const invalidProduct2 = new Product(2000); // Error: Value cannot be greater than 1000
Conclusion
Decorators are a powerful feature in TypeScript that allow us to add metadata and additional behavior to classes, methods, properties, and parameters. Although still experimental, decorators are widely used in popular frameworks and libraries such as Angular, NestJS, and TypeORM.
By understanding and leveraging decorators, we can write code that is more declarative, reusable, and expressive. Decorators allow us to implement aspects like logging, validation, and dependency injection in a clean way that is separate from the main business logic.
However, it’s important to remember that since decorators are still an experimental feature, their syntax and behavior may change in the future. Always check the latest TypeScript documentation for up-to-date information about decorators.