
Chapter Outline
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:
tsconfig.json1{2 "compilerOptions": {3 "target": "ES5",4 "experimentalDecorators": true5 }6}
Basic Example of a Decorator
Here’s a simple example of a class decorator:
typescript1function sealed(constructor: Function) {2 Object.seal(constructor);3 Object.seal(constructor.prototype);4}56@sealed7class Greeter {8 greeting: string;9 constructor(message: string) {10 this.greeting = message;11 }12 greet() {13 return "Hello, " + this.greeting;14 }15}
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.
typescript1function color(value: string) {2 return function (constructor: Function) {3 constructor.prototype.color = value;4 }5}67@color('blue')8class Car {9 model: string;10 constructor(model: string) {11 this.model = model;12 }13}
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.
typescript1function sealed(constructor: Function) {2 console.log('Sealing the constructor');3 Object.seal(constructor);4 Object.seal(constructor.prototype);5}67@sealed8class Greeter {9 greeting: string;10 constructor(message: string) {11 this.greeting = message;12 }13 greet() {14 return "Hello, " + this.greeting;15 }16}
How It's Used
- The
@sealeddecorator is applied directly before the class definition. - When an instance of
Greeteris 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.
typescript1function enumerable(value: boolean) {2 return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {3 descriptor.enumerable = value;4 };5}67class Greeter {8 greeting: string;9 constructor(message: string) {10 this.greeting = message;11 }1213 @enumerable(false)14 greet() {15 return "Hello, " + this.greeting;16 }17}
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:
typescript1function cacheable(cacheKey: string) {2 return function (3 target: any,4 propertyName: string,5 descriptor: PropertyDescriptor6 ) {7 const method = descriptor.value;89 descriptor.value = function (...args: any[]) {10 const cache = target.constructor.cache = target.constructor.cache || {};11 const key = `${cacheKey}_${JSON.stringify(args)}`;1213 if (cache[key] !== undefined) {14 console.log(`Cache hit for key: ${key}`);15 return cache[key];16 }1718 console.log(`Cache miss for key: ${key}. Computing and caching result.`);19 const result = method.apply(this, args);20 cache[key] = result;21 return result;22 };23 };24}2526class MathOperations {27 @cacheable('add')28 add(x: number, y: number): number {29 console.log(`Performing addition: ${x} + ${y}`);30 return x + y;31 }3233 @cacheable('multiply')34 multiply(x: number, y: number): number {35 console.log(`Performing multiplication: ${x} * ${y}`);36 return x * y;37 }38}
Usage
typescript1const math = new MathOperations();23console.log(math.add(5, 3)); // Cache miss, performs addition4console.log(math.add(5, 3)); // Cache hit, returns cached result56console.log(math.multiply(4, 2)); // Cache miss, performs multiplication7console.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).
typescript1function configurable(value: boolean) {2 return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {3 descriptor.configurable = value;4 };5}67class Point {8 private _x: number;9 private _y: number;1011 constructor(x: number, y: number) {12 this._x = x;13 this._y = y;14 }1516 @configurable(false)17 get x() { return this._x; }1819 @configurable(false)20 get y() { return this._y; }21}
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:
typescript1function validateEmail(target: any, propertyKey: string, descriptor: PropertyDescriptor) {2 const originalSet = descriptor.set;34 descriptor.set = function(value: string) {5 if (!/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(value)) {6 throw new Error('Invalid email format');7 }8 originalSet.call(this, value.toLowerCase()); // Normalize email to lowercase9 };10}1112function formatUsername(target: any, propertyKey: string, descriptor: PropertyDescriptor) {13 const originalSet = descriptor.set;1415 descriptor.set = function(value: string) {16 if (value.length < 3 || value.length > 20) {17 throw new Error('Username must be between 3 and 20 characters');18 }19 originalSet.call(this, value.trim()); // Trim whitespace20 };21}
User Class with Decorated Accessors
Now, we apply these decorators to a User class:
typescript1class User {2 private _email: string;3 private _username: string;45 constructor(email: string, username: string) {6 this.email = email; // Using setter7 this.username = username; // Using setter8 }910 @validateEmail11 get email(): string {12 return this._email;13 }1415 set email(value: string) {16 this._email = value;17 }1819 @formatUsername20 get username(): string {21 return this._username;22 }2324 set username(value: string) {25 this._username = value;26 }27}
Usage
typescript1try {2 const user = new User("JohnDoe@example.com", " JohnDoe ");3 console.log(user.email); // Output: johndoe@example.com4 console.log(user.username); // Output: JohnDoe5 user.email = "bad-email"; // This will throw an error6} catch (error) {7 console.error(error.message);8}
Explanation
- Email Validation and Normalization: The
validateEmaildecorator 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
formatUsernamedecorator 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.
typescript1function format(formatString: string) {2 return function (target: any, propertyKey: string) {3 Object.defineProperty(target, propertyKey, {4 get() { return formatString; },5 set(newVal) {6 formatString = newVal;7 }8 });9 };10}1112class DateRange {13 @format('YYYY-MM-DD')14 startDate: string;15 endDate: string;16}
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):
typescript1function validateRating(target: any, key: string): any {2 let value: number = target[key];34 const getter = () => value;5 const setter = (newVal: number) => {6 if (newVal < 1 || newVal > 5) {7 throw new Error('Rating must be between 1 and 5');8 }9 console.log(`Setting rating to ${newVal}`);10 value = newVal;11 };1213 Object.defineProperty(target, key, {14 get: getter,15 set: setter,16 enumerable: true,17 configurable: true18 });19}
Product Class with Decorated Property
Now, apply this decorator to a Product class:
typescript1class Product {2 @validateRating3 public rating: number;45 constructor(rating: number) {6 this.rating = rating;7 }8}
Usage
typescript1try {2 const product = new Product(4);3 console.log(product.rating); // Output: 445 product.rating = 2;6 console.log(product.rating); // Output: 278 product.rating = 6; // This will throw an error9} catch (error) {10 console.error(error.message);11}
Explanation
- Rating Validation: The
validateRatingdecorator intercepts any assignment to theratingproperty. 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.
typescript1function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {2 console.log(`Parameter at index ${parameterIndex} on ${propertyKey.toString()} is required.`);3}45class User {6 login(@required username: string, @required password: string) {7 console.log(`${username} is logging in with ${password}`);8 }9}
How It's Used
- The
@requireddecorator 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 parametersusernameandpasswordare 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:
typescript1import { createParamDecorator, ExecutionContext } from '@nestjs/common';23export const ReqQuery = createParamDecorator(4 (data: string, ctx: ExecutionContext) => {5 const request = ctx.switchToHttp().getRequest();6 const value = request.query[data];7 if (!value) {8 throw new Error(`Query parameter "${data}" is required.`);9 }10 return value;11 }12);
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:
typescript1import { Controller, Get } from '@nestjs/common';23@Controller('search')4export class SearchController {5 @Get()6 search(@ReqQuery('term') searchTerm: string) {7 return `Searching for: ${searchTerm}`;8 }9}
Explanation
- Parameter Extraction and Validation: The
ReqQuerydecorator abstracts the logic for fetching and validating a query parameter. When thesearchmethod is called, it ensures that thetermquery 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.