- Published on
Exploring TypeScript Decorators: A Guide to Their Purpose, Usage, and Practical Examples
- Authors
- Name
- Youness Hassoune
- Reading Time
Introduction
Decorators are an upcoming ECMAScript feature currently at stage 3, enabling you to flexibly and reusably modify or enhance the behavior of classes, methods, properties, or parameters.
To use decorators in JavaScript, you can use Babel with the @babel/plugin-proposal-decorator or TypeScript version 5 or newer, which supports stage 3 decorators.
In this article, we will leverage TypeScript to explore the world of decorators, how to create and utilize them. We'll also provide practical examples to showcase their usefulness.
Before we begin, I recommend reading this fantastic article on Javascript decorators and their history by Dr. Axel Rauschmayer. Additionally, you can refer to the official TypeScript 5.0 announcement for more information.
Let's get started!
Table of contents
Table of Contents
What is a decorator?
A decorator is a keyword that starts with an @
symbol and can be put on top of classes and class members (such as methods ...) , And it's used to add metadata, modify behavior, or apply additional functionality .
Let's start with class Decorators.
Class Decorators
We have a User
class with two properties, firstName and lastName, and it is decorated with the logDecoratorData
decorator. This decorator logs the data it receives, providing us with a better understanding of how to construct decorators.
@logDecoratorData
class User {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
const user1 = new User("John", "Doe");
Now let's implement the logDecoratorData:
function logDecoratorData(value: Function, context: ClassDecoratorContext) {
console.log("data received");
console.log({
value,
context,
});
}
@logDecoratorData
class User {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
const user1 = new User("John", "Doe");
The output of that console.log statement is as follows:
So, our decorator takes two arguments: the first one is named value
, which refers to the originalMethod or, in this case, our class, and the second argument is named context
of type ClassDecoratorContext, which is an object.
Now let's suppose we want to ensure that every time a User object is created, it includes a property called createdAt, which holds a date string.
Let's explore how we can achieve this using decorators.
function AddCreatedAt<T extends { new (...args: any[]): {} }>(
baseClass: T,
context: ClassDecoratorContext
) {
return class extends baseClass {
createdAt: string;
constructor(...args: any[]) {
super(...args);
this.createdAt = new Date().toISOString();
}
};
}
@AddCreatedAt
class User {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
const user1 = new User("John", "Doe");
console.log(user1)
// Output:
// {
// "firstName": "John",
// "lastName": "Doe",
// "createdAt": "2023-10-08T17:18:33.593Z"
// }
- Our
AddCreatedAt
function takes two parameters:- baseClass: A generic type T that extends a class with a constructor that takes any number of arguments.
- context: A parameter of type ClassDecoratorContext .
- Inside the
AddCreatedAt
function, a new anonymous class is defined, which extends the baseClass (the class being decorated). This new class has an additional createdAt property and a constructor that sets the createdAt property to the current date and time in ISO format when an instance is created.
Method Decorators
Let's implement a 'buy' method that enables users to make purchases and add a decorator to it to verify their balance before allowing the purchase.
function CheckBalance(originalMethod:Function,context:ClassMethodDecoratorContext){
function replacementMethod(this: any, ...args: any[]) {
const price=args[0];
if(price > this.balance){
throw new Error(`Insufficient funds to make the purchase.`);
}
const result = originalMethod.call(this, ...args);
return result;
}
return replacementMethod;
}
class User {
firstName: string;
lastName: string;
balance:number=1000;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
@CheckBalance
buy(price: number) {
this.balance -= price;
console.log(`${this.firstName} ${this.lastName} made a purchase for $${price}. New balance: $${this.balance}`);
}
}
const user1 = new User("John", "Doe");
user1.buy(50); // This will work and log the purchase.
user1.buy(951); // This will not work due to insufficient balance, and it will not log the purchase.
replacementMethod
is the actual replacement method that will be invoked when the decorated method (buy) is called. It checks if the first argument passed to the method (the price of the purchase) is greater than the user's balance. If the balance is insufficient, it throws an error. Otherwise, it calls the original method and returns its result.
Let's add another decorator to the same method that applies a discount percentage.
function CheckBalance(originalMethod:Function,context:ClassMethodDecoratorContext){
function replacementMethod(this: any, ...args: any[]) {
const price=args[0];
if(price > this.balance){
throw new Error(`Insufficient funds to make the purchase.`);
}
const result = originalMethod.call(this, ...args);
return result;
}
return replacementMethod;
}
function Discount(discountPercentage: number) {
return function (originalMethod:Function,context:ClassMethodDecoratorContext) {
function replacementMethod(this: any, ...args: any[]) {
const price=args[0];
const discountedPrice = price - (price * discountPercentage) / 100;
const result = originalMethod.call(this, discountedPrice);
return result;
};
return replacementMethod
};
}
class User {
firstName: string;
lastName: string;
balance:number=1000;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
@CheckBalance // check if the user balance is sufficient.
@Discount(10) // Apply a 10% discount to the purchase price .
buy(price: number) {
this.balance -= price;
console.log(`${this.firstName} ${this.lastName} made a purchase for $${price}. New balance: $${this.balance}`);
}
}
const user1 = new User("John", "Doe");
user1.buy(50); // This will work and log the purchase.
user1.buy(951); // This will now work due to the Discount decorator
Decorators are executed in a bottom-to-top order, meaning that @Discount
is applied first, followed by @CheckBalance
and so on.
Property Decorators
Let's enhance our User
class by adding a read only property id
, and apply a decorator that assigns a randomly generated string to it.
function Id(target:undefined,context:ClassFieldDecoratorContext){
return function(args:string){
const random = [...Array(10)].map(() => (~~(Math.random() * 36)).toString(36)).join('');
return random;
}
}
class User {
firstName: string;
lastName: string;
balance:number=1000;
@Id
readonly id! :string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
const user1 = new User("John", "Doe");
console.log(user1)
// Output
// User: {
// "firstName": "John",
// "balance": 1000,
// "id": "v8hehuzjty",
// "lastName": "Doe"
// }
Summary
Decorators in TypeScript are a versatile tool that facilitates the encapsulation and efficient reuse of cross-cutting concerns like logging, validation, dependency injection, and various other aspects of application behavior. While TypeScript supports a other types of decorators , we have focused on explaining three specific types.