Explore the Decorator Pattern in TypeScript, learn how to create custom decorators, enable experimental features, and apply best practices for effective code enhancement.
The Decorator Pattern is a structural design pattern that allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class. In TypeScript, decorators provide a powerful way to modify the behavior of classes, methods, properties, and parameters through metadata and reflection. This section delves into how TypeScript supports decorators, the syntax for creating custom decorators, and practical applications of this pattern.
Decorators in TypeScript are an experimental feature that allows you to attach metadata to classes, methods, accessors, properties, and parameters. This metadata can then be used to modify the behavior of the decorated element. The concept of decorators is inspired by the decorator pattern, but in TypeScript, they are implemented as functions that are prefixed with an @
symbol.
Before using decorators in TypeScript, you need to enable them in your project. This is done by setting the experimentalDecorators
option to true
in your tsconfig.json
file:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
This setting is necessary because decorators are not part of the ECMAScript standard yet and are considered an experimental feature in TypeScript.
TypeScript supports several types of decorators:
Each type of decorator serves a specific purpose and can be used to enhance different aspects of a class or its members.
A class decorator is a function that takes a class constructor as its only argument and returns a new constructor or modifies the existing one. Here’s a simple example:
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}`;
}
}
In this example, the sealed
decorator seals the class and its prototype, preventing any further modifications.
Method decorators are applied to the methods of a class. They receive three arguments: the target object, the name of the method, and the property descriptor of the method. Here’s how you can create a method decorator:
function log(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with arguments: ${JSON.stringify(args)}`);
return originalMethod.apply(this, args);
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
const calculator = new Calculator();
calculator.add(2, 3);
The log
decorator logs the method name and its arguments each time the method is called.
Property decorators are applied to properties of a class. They receive two arguments: the target object and the name of the property. Here’s an example:
function readonly(target: Object, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
writable: false
});
}
class Person {
@readonly
name: string = "John Doe";
}
const person = new Person();
person.name = "Jane Doe"; // Error: Cannot assign to read-only property
The readonly
decorator makes the name
property immutable.
Creating custom decorators in TypeScript involves defining a function that follows the decorator signature for the element you wish to decorate. Here’s how you can create a custom class decorator:
function timestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
return class extends constructor {
timestamp = new Date();
};
}
@timestamp
class Document {
title: string;
constructor(title: string) {
this.title = title;
}
}
const doc = new Document("My Document");
console.log(doc.timestamp); // Outputs the timestamp when the instance was created
In this example, the timestamp
decorator adds a timestamp
property to the class, which is set to the current date and time when an instance is created.
TypeScript supports metadata reflection through the reflect-metadata
library, which allows you to attach and retrieve metadata from objects. This is particularly useful for advanced decorator implementations, such as dependency injection frameworks.
To use metadata reflection, you need to install the reflect-metadata
package and import it at the top of your TypeScript file:
npm install reflect-metadata --save
import "reflect-metadata";
function logType(target: any, key: string) {
const type = Reflect.getMetadata("design:type", target, key);
console.log(`${key} type: ${type.name}`);
}
class Demo {
@logType
myProperty: string;
}
The logType
decorator uses Reflect.getMetadata
to log the type of the decorated property.
The order in which decorators are applied is important and can lead to unexpected side effects if not managed properly. Decorators are applied from top to bottom but executed in reverse order (bottom to top). Consider the following example:
function first() {
console.log("first(): evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("first(): called");
};
}
function second() {
console.log("second(): evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("second(): called");
};
}
class Example {
@first()
@second()
method() {}
}
// Output:
// first(): evaluated
// second(): evaluated
// second(): called
// first(): called
In this example, first
is evaluated before second
, but second
is called before first
.
Decorators are currently an experimental feature in TypeScript and are not part of the ECMAScript standard. However, they are being considered for inclusion in future versions of ECMAScript. The TC39 committee, which is responsible for evolving the ECMAScript language, is actively working on a proposal for decorators. This proposal may introduce changes to how decorators are implemented and used in the future.
Decorators have numerous practical applications, including:
Here’s an example of how decorators can be used for dependency injection:
import "reflect-metadata";
function Injectable() {
return function (target: any) {
Reflect.defineMetadata("injectable", true, target);
};
}
function Inject(serviceIdentifier: string) {
return function (target: any, key: string, index: number) {
const existingInjectedParameters: any[] =
Reflect.getOwnMetadata("inject", target, key) || [];
existingInjectedParameters.push({ index, serviceIdentifier });
Reflect.defineMetadata("inject", existingInjectedParameters, target, key);
};
}
@Injectable()
class ServiceA {}
@Injectable()
class ServiceB {
constructor(@Inject("ServiceA") private serviceA: ServiceA) {}
}
In this example, the Injectable
decorator marks a class as injectable, and the Inject
decorator specifies the dependencies to be injected.
The Decorator Pattern in TypeScript provides a robust mechanism for enhancing and modifying the behavior of classes and their members. By leveraging decorators, developers can create clean, maintainable, and reusable code. However, it’s important to adhere to best practices and be mindful of potential issues with decorator ordering and side effects. As decorators evolve and potentially become part of the ECMAScript standard, they will continue to play a crucial role in modern software development.