TypeScript Decorators: A Comprehensive Guide
Unlocking the Potential and Flexibility of TypeScript Decorators with Simple, Illustrative Examples for a Solid Foundation
Decorators are a powerful feature that can enhance and modify your code during compilation. Whether you’re new to TypeScript or an experienced developer, this article will show you how to harness the true power of decorators that can take your TypeScript code to the next level.
Let’s dive right in!
Introduction
Please note that decorators are a stage 2 proposal for JavaScript and are currently an experimental feature in TypeScript.
In order to use decorators, you need to enable the ‘experimentalDecorators’ compiler option, either:
- trough the command line:
tsc --taget ES5 --experimentalDecorators
- or by adding it to your tsconfig.json file:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
Decorators
A decorators in TypeScript are a special kind of feature that allows you to enhance or modify your code in a flexible way. They provide a way to attach additional functionality to classes, methods, properties, or parameters.
Decorators use the form
@expression
, whereexpression
must evaluate to a function that will be called at runtime with information about the decorated declaration.
— TypeScript docs
Here’s a simple example to illustrate the above:
// define a decorator function called 'myDecorator'
function myDecorator(target: any) {
console.log("Decorating:", target.name);
}
@myDecorator
class MyClass {
// Class implementation...
}
// output: "Decorating: MyClass"
The @myDecorator
syntax is used to apply the decorator to the MyClass
declaration. When the code is executed, the myDecorator
function will be called with the MyClass
constructor function as the target
parameter.
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.
— TypeScript Docs
In some cases, we may need to customize the behavior of a decorator by passing additional options. This is where decorator factories come into play.
A Decorator Factory is a function that returns another function, which acts as the actual decorator. It allows us to configure and customize the behavior of the decorator before it is applied to a target.
const decoratorFactory = (value: string) => {
// The decorator factory returns a decorator function
return (target: any) => {
// The returned decorator function uses 'target' and 'value' to apply the decorator logic
// ...
};
};
TypeScript Decorators in Practice
Class Decorators
Class decorators are a type of decorator that can be applied to classes.
They -
- allow you to modify or extend the behavior of a class or its constructor, and can even replace the entire class with a new one.
- receive the class constructor as their target and can be used to add properties, methods, or modify metadata.
- are executed at runtime when the class is defined. By having access to the class constructor, they enable behavior manipulation before any instances are created.
Have a look at the example below:
function uppercaseConstructor<T extends new (...args: any[]) => any>(target: T): T {
const originalConstructor = target;
function modifiedConstructor(...args: any[]) {
const instance = new originalConstructor(...args);
Object.keys(instance).forEach((key) => {
if (typeof instance[key] === 'string') {
instance[key] = instance[key].toUpperCase();
}
});
return instance;
}
modifiedConstructor.prototype = originalConstructor.prototype;
return modifiedConstructor as unknown as T; // This type assertion bypasses TypeScript's allows us to assume that the returned value will match type T, even if there is no direct type relationship.
}
@uppercaseConstructor
class ExampleClass {
constructor(private name: string, private city: string) {}
displayInfo() {
console.log(`Name: ${this.name}, City: ${this.city}`);
}
}
const example = new ExampleClass("John Doe", "New York");
example.displayInfo();
The class decorator uppercaseConstructor
converts all string properties of the class instance to uppercase.
Inside the decorator, we define a modifiedConstructor
function that replaces the original constructor. This modified constructor creates an instance of the original constructor and then iterates over its properties. If a property value is a string, it converts it to uppercase.
We also ensure that the prototype of the modified constructor is set to be the same as the original constructor’s prototype.
When the ExampleClass
is defined with the @uppercaseConstructor
syntax, the decorator is invoked. It modifies the constructor to transform string properties to uppercase when an instance is created.
Finally, the output is:
// Name: JOHN DOE, City: NEW YORK
Method Decorators
Method decorators are a type of decorator that can be applied to class methods (both instance methods and static methods), allowing you to modify or extend the behavior of a specific method. They -
- have the ability to access and modify method arguments, return values, or even replace the method entirely.
- are functions that receive three parameters: the target object (class prototype), the method name, and the property descriptor (an object that describes the attributes and behavior of a property).
- are executed when the class is defined, allowing for modification of method behavior before any instances are created.
Let’s see an example (inspired by the TypeScript’s 5.0 release notes):
// no decorators here
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
console.log("LOG: Entering method."); // can be extracted
console.log(`Hello, my name is ${this.name}.`);
console.log("LOG: Exiting method."); // can be extracted
}
}
Since the logic in greet
can be needed in other functions, there are parts in it that we can extract. We can write a function called loggedMethod
that looks like the following:
function loggedMethod(_target: any, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalMethod = descriptor.value;
function replacementMethod(this: any, ...args: any[]) {
console.log("LOG: Entering method."); // extracted
const result = originalMethod.call(this, ...args);
console.log("LOG: Exiting method."); // extracted
return result;
}
descriptor.value = replacementMethod;
return descriptor;
}
The line const result = originalMethod.call(this, …args);
is responsible for invoking the original method and capturing its result. By using it, the decorator is able to execute the original method within the context specified by this
and with the provided arguments …args
.
Now we can use loggedMethod
to decorate the method greet
. It will replace the original definition of greet
:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ron");
p.greet();
// Output:
// LOG: Entering method.
// Hello, my name is Ron.
// LOG: Exiting method.
Decorator Composition
Decorator composition is the concept of applying multiple decorators to a target in a specific order to achieve a desired behavior or functionality. It allows you to combine and chain decorators to modify or extend a behavior.
Multiple decorators can be applied to a declaration in TypeScript either on a single line or on multiple lines. The order of the decorators matters because each decorator builds upon the modifications made by the previous decorator.
The example and output below clearly demonstrate how to use it effectively:
function first() {
console.log("first(): factory evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("first(): called");
};
}
function second() {
console.log("second(): factory evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("second(): called");
};
}
class ExampleClass {
@first()
@second()
method() {}
}
“1. The expressions for each decorator are evaluated top-to-bottom.”
“2. The results are then called as functions from bottom-to-top.”
// first(): factory evaluated
// second(): factory evaluated
// second(): called
// first(): called
Property Decorators
Property decorators are a type of decorator that can be applied to class properties (both instance and static). They —
- provide a way to customize or enhance the behavior of specific properties within a class before any instances are instantiated. For example, by using the static method Object.defineProperty you can customize various aspects of a property, such as its value, writability, enumerability, and configurability.
- are functions that receive two parameters: the target object (the prototype of the class for an instance member OR the constructor function of the class for a static member) and the property name.
- are executed when the class is defined, not when instances of the class are created.
Here’s an example:
function readonly(target: any, propertyName: string) {
Object.defineProperty(target, propertyName, {
writable: false,
});
}
class ExampleClass {
@readonly
name: string = "John";
}
const example = new ExampleClass();
example.name = "Alice"; // Error: Cannot assign to 'name' because it is a read-only property
Inside the decorator, we use Object.defineProperty
to redefine the property with the writable attribute set to false. This makes the property read-only.
When the @readonly
syntax is used to apply the decorator to the name property, the decorator is invoked. It modifies the property’s descriptor to make it read-only.
Finally, when we create an instance of ExampleClass
and try to assign a new value to the name property, it will result in an error.
Parameter decorators
Parameter decorators in TypeScript allow you to modify the behavior or metadata of function parameters. They are decorators specifically applied to parameters within a function declaration, right before the parameter declaration, and can be used to intercept, modify, or track the values passed to a function:
function ParameterLogger(
target: any,
methodName: string,
parameterIndex: number) {
console.log(`Parameter ${parameterIndex + 1} of ${methodName} has been accessed.`);
}
class ExampleClass {
exampleMethod(@ParameterLogger param1: string, @ParameterLogger param2: number) {
console.log("Inside exampleMethod");
}
}
const exampleInstance = new ExampleClass();
exampleInstance.exampleMethod("Hello", 42);
Here, we defined a parameter decorator called ParameterLogger
which takes three parameters.
- The
target
parameter represents the object that the method is a property of (the constructor function of the classExampleClass
).
The ‘target’ parameter typically represents the target of the decorator, which can be the constructor function for a class, the prototype of a class for an instance member, or the constructor function of a class for a static member. - The
methodName
is the name of the method where the parameter is declared (exampleMethod
). - The
parameterIndex
is the index of the parameter within the method’s parameter list.
The output is:
// Parameter 1 of exampleMethod has been accessed.
// Parameter 2 of exampleMethod has been accessed.
// Inside exampleMethod
Pretty straightforward! 😃
Conclusion
In this article we started with defining decorators and decorator factories and progressed to showing how they are used in different roles, such as — Class Decorators, Parameter decorators, Property decorators and Method Decorators.
Typing decorators can be fairly complex, but they are also very handy when it comes to writing more modular, reusable, and flexible code. They enhance developer productivity and make it easier to maintain and evolve your TypeScript applications.
I hope you found this post useful. ✨
Stay tuned for future content! If you’re new here, feel free to explore my other articles, and follow me for updates and more valuable insights.
By buying me a virtual croissant on Buy Me a Coffee, you can directly support my creative journey. Your contribution helps me continue creating high-quality content. Thank you for your support!