TypeScript Learning Adventures: A Tale of Love and Hate - Decorators
A feature that can be quite helpful for meta-programming is decorators. What does "meta-programming" actually imply now? Decorators are an excellent tool for creating code that is simpler for other developers to use.
A feature that can be quite helpful for meta-programming is decorators. What does "meta-programming" actually imply now?
Decorators are an excellent tool for creating code that is simpler for other developers to use. You'll see what I mean throughout this post, albeit that might sound unusual at first.
We don't work on any user-based functionality with decorators. Instead, we may, for instance, ensure that one of our classes is used in a good way. or a class's method. Or we perform various hidden transformations, etc.
How to create a decorator?
The first step is to set up your tsconfig.json
file to enable decorators. To do this, set the compilerOptions
property experimentalDecorators
to true
. Also, check that your target is set to ES6 (or above).
You won't be able to use decorators for your project if you don't do this. Thus, change your tsconfig.json
file as this is necessary.
I'd like to start with a class decorator. We can create a new class for books like this:
class Book {
title = "Adrenaline";
author = "Zlatan Ibrahimović";
constructor() {
console.log("creating book...");
}
}
const book = new Book();
console.log(book);
/*
"creating book..."
{
author: "Zlatan Ibrahimović",
title: "Adrenaline"
}
*/
Applying a decorator to this class is the next step.
The decorator is simply a function, which is crucial to understand. It is a method you use to apply something, like a class, to something inside a class.
function WithLog(target: Function) {
console.log("Some message with log...");
console.log(target);
}
@WithLog
class Book {
title = "Adrenaline";
author = "Zlatan Ibrahimović";
constructor() {
console.log("creating book...");
}
}
const book = new Book();
console.log(book);
This is a function, and the only unique aspect of it is that it begins with a capital letter. By the way, using a capital starting character is not required. You can also use a lowercase one. Many decorators in libraries use uppercase starting characters.
It will produce some console logs (to keep things simple). As you can see, we add a @ symbol here before the class and then our function here is to add a decorator to a class.
Here are a few unique things you need to know.
The @ symbol here is a special identifier TypeScript sees or recognizes. And then the thing after the @ symbol should point at a function.
Not execute it, but point at it, which should be your decorator.
Decorators can also receive arguments. That's the target of this decorator so to say, which is our constructor function. So we can say we get a function here as an argument in the end.
If we build this and look at the results, we can see that:
"Some message with log..."
function Book() {
this.title = "Adrenaline";
this.author = "Zlatan Ibrahimović";
console.log("creating book...");
}
"creating book..."
{
author: "Zlatan Ibrahimović",
title: "Adrenaline"
}
As you can see the whole class is here in the target log. The classes in the end are just some tactical sugar over constructor functions.
Note that our decorator output, WithLog, and this class or this constructor function log here are printed first before we see "creating book..." and our book object.
Because, indeed decorators execute when your class is defined. Not when it is instantiated.
And that's really important to know.
You don't need to instantiate your class at all. We could remove that code for instantiating the class. we would still get that decorator output.
So the decorator runs when JavaScript finds your class definition. Not when you use that constructor function to instantiate an object.
That's also important to understand!
More complex decorators
The previous example was a primitive one to get started so let's create a more complex decorator:
function WithLog(message: string) {
return function (constructor: Function) {
console.log("Some message with log...");
console.log(message);
};
}
function MeasureTime(target: any) {
const startTime = Date.now();
const originalConstructor = target;
const newConstructor: any = function (...args: any[]) {
const instance = new originalConstructor(...args);
const endTime = Date.now();
console.log(`${endTime - startTime}ms`);
return instance;
};
newConstructor.prototype = originalConstructor.prototype;
return newConstructor;
}
@MeasureTime
@WithLog("Time taken:")
class Book {
title = "Adrenaline";
author = "Zlatan Ibrahimović";
constructor() {
console.log("creating book...");
}
}
setTimeout(() => {
const book = new Book();
console.log(book);
}, 3000);
MeasureTime is a decorator for measuring time from class definition to class instantiation. The output is the following:
"creating book..."
"Time taken: 3002ms"
{
author: "Zlatan Ibrahimović",
title: "Adrenaline"
}
This decorator is specific because it is modifying our class by adding new functionality to it.
There is also a second decorator WithLog which takes an extra argument. This is a decorator factory, which returns a decorator function. It also allows us to configure it when we assign it as a decorator to something.
So now we have a function that returns a new function. Notice the parentheses here:
@WithLog("Time taken:")
Then when we want to apply this type of decorator that returns a function, we have to execute it as a function. So when we execute this outer function and attach the return value, we get a result.
Using decorator factories can give us more possibilities for configuring decorator logic.
How to add a decorator to a class property?
We can add decorators to classes but there are more places where we can add them.
You can also add them to your class properties.
Let's imagine that we need to always display the title and author of the book in uppercase letters. You can do this by applying the decorator that will do exactly that:
function Uppercase(target: any, propertyKey: string) {
let value = target[propertyKey];
const getter = () => value;
const setter = (newValue: string) => {
value = newValue.toUpperCase();
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
}
class Book {
@Uppercase
title: string;
@Uppercase
author: string;
constructor(newTitle: string, newAuthor: string) {
this.title = newTitle;
this.author = newAuthor;
}
getTitleWithAuthor() {
return `${this.author} - ${this.title}`;
}
}
const book = new Book("Adrenaline", "Zlatan Ibrahimović");
console.log(book.getTitleWithAuthor()); // "ZLATAN IBRAHIMOVIĆ - ADRENALINE"
Decorator Uppercase
is a bit different as it operates on the class property. It receives 2 arguments:
- The first argument is the target of the property. For an instance property like this one, this will be the prototype of the object that was created. If we had a static property here, the target would refer to the constructor function state. So here we set type to
any
because we don't know exactly which structure the object will have. - The second argument we get is the property name. That could be a string here.
So we know that each class property has a set of its own properties that are used in the return value. In our case, it consists of:
- get - to get the property value
- set - to set the property value
- enumerable - flag to check if the property is enumerable
- configurable - flag to check if the property can be changed or deleted in some cases
And you are free to play with these properties. So in our case, we define a getter and setter for class properties with our decorator.
With the help of Object.defineProperty
we can define a new property on an object. Or we can modify an existing property on an object. So we are modifying existing property with a specific property key on our target. We are then assigning a new set of values.
How to add a decorator to the setter?
Besides properties, you can also add decorators to the getters and setters. So let's implement a decorator for a setter method which will do some audit logs.
Audit logs are an important feature in some regulated domains of work. They enable you following pieces of information:
- who changes the data
- when the data was changed
- how the data was changed, etc.
Here is how it looks:
function AuditLog(target: any, methodName: string, descriptor: PropertyDescriptor) {
const originalSetter = descriptor.set;
descriptor.set = function (newValue: any) {
const propertyName = `_${methodName}`;
const oldValue = this[propertyName];
originalSetter.call(this, newValue);
console.log(
`Property '${propertyName}': Old Value = ${oldValue}, New Value = ${newValue}`
);
};
return descriptor;
}
class Book {
private _title: string;
author: string;
@AuditLog
set title(newTitle: string) {
this._title = newTitle;
}
constructor(newTitle: string, newAuthor: string) {
this._title = newTitle;
this.author = newAuthor;
}
getTitleWithAuthor() {
return `${this.author} - ${this._title}`;
}
}
const book = new Book("Adrenaline", "Zlatan Ibrahimović");
book.title = "I am Zlatan";
//"Property '_title': Old Value = Adrenaline, New Value = I am Zlatan"
There are differences between a direct-class property decorator and a getter/setter decorator. The first 2 arguments are the same, but here we have a third one of the type PropertyDescriptor.
If you log all these 3 arguments, you will see the following:
{
descriptor: {
configurable: true,
enumerable: false,
get: undefined,
set: function (newTitle) {
this._title = newTitle;
}
},
methodName: "title",
target: {
getTitleWithAuthor: function () {
return "".concat(this.author, " - ").concat(this._title);
}
}
}
We got:
target
- our prototype againmethodName
- the name of our accessor,title
in this case. Not_title
. So not the property with which it deals internally. Instead, it is the name of the accessor itself.descriptor
- property descriptor. Here where we see that a setter function is defined. The getter function is not defined, because for title only has a setter, no getter. And we see that it's not enumerable, but that it is configurable. So that we can change this definition here for example we can delete it and so on.
The rest of the code is accessing values and printing logs. In a real project, you can save it in the database or do whatever you want with that information.
How to add a decorator to a class method?
This one is the same as for setter/getter.
Let's implement an example for a decorator that will log the execution timestamp on a specific class method:
function WithExecutionTimestamp(target: any, methodName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[${new Date().toISOString()}] Executing ${methodName}`);
const result = originalMethod.apply(this, args);
return result;
};
return descriptor;
}
class Book {
title: string;
author: string;
constructor(newTitle: string, newAuthor: string) {
this.title = newTitle;
this.author = newAuthor;
}
@WithExecutionTimestamp
getTitleWithAuthor() {
return `${this.author} - ${this.title}`;
}
}
const book = new Book("Adrenaline", "Zlatan Ibrahimović");
console.log(book.getTitleWithAuthor())
// "[2023-06-25T10:11:01.046Z] Executing getTitleWithAuthor"
// "Zlatan Ibrahimović - Adrenaline"
The decorators for class methods receive the same 3 arguments as in the previous example for getter/setter.
You can also use decorators to manipulate method parameters. So let's say you want to mask the parameter for security reasons.
You can do it like in this example here:
function Encrypt(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const modifiedArgs = args.map((arg, index) => {
if (index === 0) {
// Assuming only the first parameter needs to be modified
return arg.toString().replace(/./g, "*");
}
return arg;
});
return originalMethod.apply(this, modifiedArgs);
};
return descriptor;
}
class User {
private _name: string;
constructor(newName: string) {
this._name = newName;
}
@Encrypt
checkSecretPhrase(secretPhrase: string) {
console.log(`Executing myMethod with encrypted parameter: ${secretPhrase}`);
}
}
const user = new User("Zlatan Ibrahimović");
user.checkSecretPhrase("I love AC Milan");
// "Executing myMethod with encrypted parameter: ***************"
Multiple decorators and order of execution
I wanna conclude this article with another important thing.
You can add more than one decorator to a class, or anywhere else where you can use a decorator. This leaves one important question, in which order do these decorators execute?
Well for that, to find out, let's create 2 simple decorators and call them:
function First(message: string) {
return function (constructor: Function) {
console.log("First decorator message: ", message);
};
}
function Second(message: string) {
return function (constructor: Function) {
console.log("Second decorator message: ", message);
};
}
@First("I am first!")
@Second("I am second!")
class User {
private _name: string;
constructor(newName: string) {
this._name = newName;
}
}
const user = new User("Zlatan Ibrahimović");
// "Second decorator message: ", "I am second!"
// "First decorator message: ", "I am first!"
And we see the Second
decorator runs first, and then we get the output from the First
one.
Now what this tells us of course, is that they execute bottom-up. The bottom-most decorator first, then thereafter, the decorators above it.
I'm talking about the actual decorator functions.
The decorator factories here run earlier, so let's change the code a bit to see what I am talking about:
function First(message: string) {
console.log("First");
return function (constructor: Function) {
console.log("First decorator message: ", message);
};
}
function Second(message: string) {
console.log("Second");
return function (constructor: Function) {
console.log("Second decorator message: ", message);
};
}
@First("I am first!")
@Second("I am second!")
class User {
private _name: string;
constructor(newName: string) {
this._name = newName;
}
}
const user = new User("Zlatan Ibrahimović");
// "First"
// "Second"
// "Second decorator message: ", "I am second!"
// "First decorator message: ", "I am first!"
This makes sense because in the end, even though we got this @ symbol here in the class, we are executing a function.
And of course, regular JavaScript rules apply here. The First
function execution happens before the Second
function execution. This is why we see the "First" message before we see the "Second" message.
So the creation of our actual decorator functions happens in the order in which we specify these factory functions. But the execution of the actual decorator functions then happens bottom up.
It's something you have to know.
Conclusion
In conclusion, decorators in TypeScript offer a powerful and flexible way to enhance the behavior of classes and their members.
By allowing us to wrap, modify, or extend existing functionality, decorators enable us to separate concerns, enhance code reuse, and promote cleaner and more maintainable code.
In conclusion, decorators in TypeScript are a valuable addition to our developer toolkit. They empower us to write cleaner, more modular code by separating concerns, manipulating behavior, and controlling execution order.
With decorators, we can take our TypeScript projects to new heights of flexibility, maintainability, and extensibility.
Comments ()