TypeScript Decorators: Leveraging Metadata in Your Applications
TypeScript decorators provide a powerful and expressive way to modify and annotate class declarations and members. Decorators can be attached to a class declaration, method, accessor, property, or parameter. In this article, we'll explore how decorators work, how to use them effectively, and real-world scenarios where they can significantly enhance your application's architecture.
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 form @expression
, where expression
must evaluate to a function that will be called at runtime with information about the decorated declaration.
To enable experimental support for decorators in TypeScript, you need to enable the experimentalDecorators
compiler option in your tsconfig.json
:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
Basic Example of a Decorator
Here’s a simple example of a class decorator:
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@sealed
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
The @sealed
decorator will seal both the constructor and its prototype so that no new properties can be added to them.
Decorator Factories
If we want to customize how a decorator is applied to a declaration, we can write a decorator factory. A decorator factory is simply a function that returns the expression that will be called by the decorator at runtime.
function color(value: string) {
return function (constructor: Function) {
constructor.prototype.color = value;
}
}
@color('blue')
class Car {
model: string;
constructor(model: string) {
this.model = model;
}
}
In this example, @color('blue')
is a decorator factory. When Car
class is instantiated, it will have an additional color
property set to 'blue'.
Types of Decorators and Their Usage
TypeScript supports several types of decorators:
1. Class Decorators
Class decorators are applied to the constructor of the class and can be used to modify or replace the class definition.
function sealed(constructor: Function) {
console.log('Sealing the constructor');
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@sealed
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
return "Hello, " + this.greeting;
}
}
How It's Used
- The
@sealed
decorator is applied directly before the class definition. - When an instance of
Greeter
is created, the class constructor and its prototype are sealed, meaning no new properties can be added to them, and existing properties cannot be removed. This is useful for finalizing an API of a class to ensure stability and consistency.
Real-World Use Case
Imagine you are developing a library or a framework where you need to ensure that the consumers do not accidentally modify the structure of core classes. Using a sealed decorator can prevent such modifications, thereby preventing potential bugs and ensuring that everyone uses the API as intended. This is especially useful in scenarios where stability and consistency of the API are critical, such as in SDKs for third-party use or in large-scale enterprise applications where multiple teams are working on the same codebase.
2. Method Decorators
Method decorators are applied to method definitions and can observe, modify, or replace method definitions.
function enumerable(value: boolean) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.enumerable = value;
};
}
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
@enumerable(false)
greet() {
return "Hello, " + this.greeting;
}
}
How It's Used
The @enumerable(false)
decorator modifies the enumerable
attribute of the greet
method. This means the greet
method will not show up if the object’s methods are iterated over in a for...in
loop. This can be useful for methods that should be hidden from certain types of introspection.
Real-World Use Case: Caching values
Creating a cacheable method decorator in TypeScript can enhance the performance of your application by avoiding redundant executions of expensive function calls. This decorator will check if the result of the method is already cached; if so, it retrieves the result from the cache, otherwise, it executes the method and caches the result.
Here is an example of how you might implement the cacheable decorator:
function cacheable(cacheKey: string) {
return function (
target: any,
propertyName: string,
descriptor: PropertyDescriptor
) {
const method = descriptor.value;
descriptor.value = function (...args: any[]) {
const cache = target.constructor.cache = target.constructor.cache || {};
const key = `${cacheKey}_${JSON.stringify(args)}`;
if (cache[key] !== undefined) {
console.log(`Cache hit for key: ${key}`);
return cache[key];
}
console.log(`Cache miss for key: ${key}. Computing and caching result.`);
const result = method.apply(this, args);
cache[key] = result;
return result;
};
};
}
class MathOperations {
@cacheable('add')
add(x: number, y: number): number {
console.log(`Performing addition: ${x} + ${y}`);
return x + y;
}
@cacheable('multiply')
multiply(x: number, y: number): number {
console.log(`Performing multiplication: ${x} * ${y}`);
return x * y;
}
}
Usage
const math = new MathOperations();
console.log(math.add(5, 3)); // Cache miss, performs addition
console.log(math.add(5, 3)); // Cache hit, returns cached result
console.log(math.multiply(4, 2)); // Cache miss, performs multiplication
console.log(math.multiply(4, 2)); // Cache hit, returns cached result
3. Accessor Decorators
Accessor decorators are similar to method decorators but are specifically used to observe, modify, or replace accessors (getters/setters).
function configurable(value: boolean) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.configurable = value;
};
}
class Point {
private _x: number;
private _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
@configurable(false)
get x() { return this._x; }
@configurable(false)
get y() { return this._y; }
}
How It's Used
The @configurable(false)
decorator makes the getter properties for x
and y
non-configurable, meaning these properties cannot be deleted or changed to another property descriptor. This is crucial for encapsulation in object-oriented design where some properties must remain immutable.
Real-world example: User Profile Management System
Consider a user profile management system where you need to ensure that:
- Email addresses are always stored in a standardized format.
- Usernames must adhere to specific validation rules.
This is an ideal case for using accessor decorators, as they can help encapsulate the validation and normalization logic directly within the class that represents a user profile.
Implementing Accessor Decorators
First, let's define a couple of decorator functions, validateEmail and formatUsername, which will be used to decorate accessors:
function validateEmail(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSet = descriptor.set;
descriptor.set = function(value: string) {
if (!/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(value)) {
throw new Error('Invalid email format');
}
originalSet.call(this, value.toLowerCase()); // Normalize email to lowercase
};
}
function formatUsername(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalSet = descriptor.set;
descriptor.set = function(value: string) {
if (value.length < 3 || value.length > 20) {
throw new Error('Username must be between 3 and 20 characters');
}
originalSet.call(this, value.trim()); // Trim whitespace
};
}
User Class with Decorated Accessors
Now, we apply these decorators to a User class:
class User {
private _email: string;
private _username: string;
constructor(email: string, username: string) {
this.email = email; // Using setter
this.username = username; // Using setter
}
@validateEmail
get email(): string {
return this._email;
}
set email(value: string) {
this._email = value;
}
@formatUsername
get username(): string {
return this._username;
}
set username(value: string) {
this._username = value;
}
}
Usage
try {
const user = new User("JohnDoe@example.com", " JohnDoe ");
console.log(user.email); // Output: johndoe@example.com
console.log(user.username); // Output: JohnDoe
user.email = "bad-email"; // This will throw an error
} catch (error) {
console.error(error.message);
}
Explanation
- Email Validation and Normalization: The
validateEmail
decorator is used to ensure that any email address set on a User instance is valid according to a specified regex pattern. If the email is valid, it normalizes the email to lowercase before setting it. - Username Formatting: The
formatUsername
decorator checks that the username is within a specific length and removes any leading or trailing whitespace.
4. Property Decorators
Property decorators are used to modify the properties of a class.
function format(formatString: string) {
return function (target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get() { return formatString; },
set(newVal) {
formatString = newVal;
}
});
};
}
class DateRange {
@format('YYYY-MM-DD')
startDate: string;
endDate: string;
}
How It's Used
The @format
decorator dynamically changes the getter and setter of the startDate
property to enforce a specific string format. Whenever startDate
is accessed, it returns a format string, and when a new value is set, it updates the format string. This ensures data consistency, especially useful for properties that require validation or a specific format (like dates or phone numbers).
Real-world example: E-commerce Application with Product Rating
Imagine you're developing an e-commerce platform, and you want to ensure that all product ratings fall within a specific range before being assigned to a product. This is critical to maintain data integrity and prevent invalid operations. A property decorator can be used to validate and modify the product rating as it's assigned.
Implementing Property Decorators
First, let's define a validateRating
property decorator that ensures the rating is within the allowed range (1 to 5):
function validateRating(target: any, key: string): any {
let value: number = target[key];
const getter = () => value;
const setter = (newVal: number) => {
if (newVal < 1 || newVal > 5) {
throw new Error('Rating must be between 1 and 5');
}
console.log(`Setting rating to ${newVal}`);
value = newVal;
};
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
Product Class with Decorated Property
Now, apply this decorator to a Product
class:
class Product {
@validateRating
public rating: number;
constructor(rating: number) {
this.rating = rating;
}
}
Usage
try {
const product = new Product(4);
console.log(product.rating); // Output: 4
product.rating = 2;
console.log(product.rating); // Output: 2
product.rating = 6; // This will throw an error
} catch (error) {
console.error(error.message);
}
Explanation
- Rating Validation: The
validateRating
decorator intercepts any assignment to therating
property. It validates the new value to ensure it falls within the acceptable range (1 to 5). If the value is out of range, it throws an error, preventing the assignment.
5. Parameter Decorators
Parameter decorators are applied to the parameters of class methods or constructors.
function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
console.log(`Parameter at index ${parameterIndex} on ${propertyKey.toString()} is required.`);
}
class User {
login(@required username: string, @required password: string) {
console.log(`${username} is logging in with ${password}`);
}
}
How It's Used
- The
@required
decorator can be used to log, validate, or enforce rules whenever certain parameters are used in methods or constructors. In this example, it logs a message indicating that the parametersusername
andpassword
are required, which could be expanded to throw an error if these parameters are not provided, enhancing the robustness of method implementations.
Real-world example: API Development in NestJS
Let's consider a practical scenario in a RESTful API developed using NestJS, a popular framework for building efficient, reliable, and scalable server-side applications. NestJS heavily utilizes decorators for various purposes, including routing, dependency injection, and custom middleware.
In this example, we'll create a parameter decorator that automatically extracts and validates a specific query parameter from an API request.
Implementing a Parameter Decorator
We'll define a parameter decorator called ReqQuery
which checks if a required query parameter is present in the request. It will throw an error if the parameter is missing, ensuring that the subsequent controller method only runs when all necessary parameters are provided.
Here's how you might implement such a decorator:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const ReqQuery = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const value = request.query[data];
if (!value) {
throw new Error(`Query parameter "${data}" is required.`);
}
return value;
}
);
Usage in a NestJS Controller
Now, let's use the ReqQuery
decorator in a NestJS controller to handle a specific route where a query parameter is mandatory:
import { Controller, Get } from '@nestjs/common';
@Controller('search')
export class SearchController {
@Get()
search(@ReqQuery('term') searchTerm: string) {
return `Searching for: ${searchTerm}`;
}
}
Explanation
- Parameter Extraction and Validation: The
ReqQuery
decorator abstracts the logic for fetching and validating a query parameter. When thesearch
method is called, it ensures that theterm
query parameter is present in the request. If the parameter is missing, it throws an error before the method body executes. - Decoupling: This approach keeps the validation logic decoupled from the business logic in the controller, leading to cleaner and more maintainable code.
Conclusion
TypeScript decorators offer a declarative and flexible way to add metadata and modify the behavior of classes, properties, methods, and parameters. By understanding how to effectively use each type of decorator, developers can write more maintainable, readable, and robust TypeScript applications. Decorators encapsulate functionality that can be reused and are particularly useful in large-scale applications, frameworks, and libraries where consistent behavior across different parts of the application is crucial.
References
By understanding and leveraging decorators, TypeScript developers can write more concise, readable, and maintainable code that encapsulates complex logic around how properties and methods behave. Decorators provide an elegant way to clearly separate concerns and extend functionality in a maintainable manner.