Decorators in TypeScript
In this post we will take a look at Decorators
in TypeScript and do some examples.
But first, what is decorators
? Well, it is basically a way of changing the behaviour of the property that we are wrapping it with. It can be a class, a function or a property of a class.
Decorators in TypeScript are marked as experimental
, however, they have been around for quite some time and are stable enough to be used in production. To read more in depth about decorators, have a look at the TS docs.
Now, lets begin with some practical examples.
Prerequisites
- TypeScript project set up
Apply this to tsconfig.json:
{ "compilerOptions": { "experimentalDecorators": true } }
Let start with a class decorator
In our first example, we will create and use a class decorator
.
Let's define our class first:
@ElectricVehicle @Logging export class Tesla { batteryPercentage: number = 0; }
And now, we will create our decorators:
class Logger { name: string; constructor(name: string) { this.name = name; } log(msg: string) { console.log(`${this.name} is logging: ${msg}`); } } function ElectricVehicle<T extends { new (...args: any[]): {} }>( constructor: T ) { return class extends constructor { batteryPercentage = 100; }; } function Logging<T extends { new (...args: any[]): {} }>(constructor: T) { return class extends constructor { logger = new Logger(constructor.name); }; }
In above example, we see the following:
- A Logger class
- A ElectricVehicle decorator function
- A Logging decorator function
Logger class is a simple class which will basically have one function for logging, it will be provided by the name of the class in the decorator function.
The ElectricVehicle will by default, always add 100 to the batteryPercentage
. And even though we set it to 0
in the Tesla class, the decorator value will override it.
Example:
const myTesla = new Tesla(); console.log(myTesla.batteryPercentage); // This will print 100
Our Logging decorator will add a new property logger
to the class which are being wrapped with @Logging
. It will provide the constructor name to the Logger
class.
Example of an object of the class Tesla
:
const myTesla = new Tesla(); console.log(myTesla); // Tesla {batteryPercentage: 100, logger: Logger}
Let's add some method decorators
All right, now we will continue with method decorators and see how we can use them all together.
Start with creating a function drive()
in Tesla
class and give it some simply logic:
public drive(requestedPercentage: number) { this.batteryPercentage = this.batteryPercentage - requestedPercentage; }
So, here we are substracting the batteryPercentage with the requested amount of percentage. Let's see how we can use decorators to prevent the batteryPercentage to getting below 0.
Let's create a method decorator and give it some logic:
const RangeChecker = (minPercentage: number) => (target: Object, propertyKey: string, descriptor: any) => { const originalMethod = descriptor.value; descriptor.value = function (requestedPercentage: number, ...args: any[]) { const percentageAfterDrive = this.batteryPercentage - requestedPercentage; if (percentageAfterDrive > minPercentage) { originalMethod.apply(this, [...args, requestedPercentage]); } else { console.error( `Not enough battery. Wanted to use ${requestedPercentage}%. But only have ${this.batteryPercentage}% left. Minimum is: ${minPercentage}%` ); } }; return descriptor; };
In this example, we are using decorator factory
pattern. We basically want to provide the decorator with some initial value, which we will later use to check the percentage. So the value minPercentage
can be different depending on the method we are applying it to.
Usage:
@RangeChecker(10) public drive(requestedPercentage: number) { this.batteryPercentage = this.batteryPercentage - requestedPercentage; }
So now our RangeChecker
will make sure we always have at least 10 percent left in battery.
And now we also want to use our Logging
functionality that we applied, so let's create a Log
method decorator, and we will check how we can use the Logger
class decorator in our method decorator.
Let's create our Log
decorator:
interface LogDescriptor extends PropertyDescriptor { logger?: Logger; } function Log(target: any, propertyKey: string, descriptor: LogDescriptor) { const originalMethod = descriptor.value; descriptor.value = function (...args: any[]) { this.logger?.log(`Calling function ${propertyKey} with args: ${args}`); const result = originalMethod.apply(this, args); return result; }; return descriptor; }
All right, so what we are doing here, is basically using the logger
property, that our Logger
class decorator was applying to the class it was wrapping, and logging the function name and arguments for the method we are wrapping it with.
Example usage:
@Log @RangeChecker(10) public drive(requestedPercentage: number) { this.batteryPercentage = this.batteryPercentage - requestedPercentage; }
Cool. So now our full Tesla
class looks like this:
@ElectricVehicle @Logging export class Tesla { batteryPercentage: number = 0; @Log @RangeChecker(10) public drive(requestedPercentage: number) { this.batteryPercentage = this.batteryPercentage - requestedPercentage; } }
Let's try it out
const myTesla = new Tesla(); myTesla.drive(70); // Tesla is logging: Calling function drive with args: 70 (Log decorator) myTesla.drive(50); // Tesla is logging: Calling function drive with args: 50 (Log decorator) // Error: Not enough battery. Wanted to use 50%. But only have 30% left. Minimum is: 10% (RangeChecker decorator)
Summary
There we have it folks, Decorators in TypeScript.
Are they useful? I would say that you can survive fine without them, but they do provide cool possibilities for us as developers to be creative and enhance our code. Also, e.g Angular are using decorators in their framework, like the @NgModule, @Component and @Input() for example. It can always be good to know what is going on underneath the hood when writing code, not to mention when debugging errors.
Hope you enjoyed it!