Deep Dive into TypeScript Decorators

Introduction

TypeScript decorators are a powerful feature that allows you to add metadata to classes, methods, properties, and parameters. They provide a way to modify or enhance the behavior of your code without changing its structure.

decorators

Decorators are a stage 3 proposal for JavaScript and are available as an experimental feature in TypeScript. They are functions that can be attached to classes, methods, properties, or parameters to modify their behavior or add metadata.

To use decorators in TypeScript, you need to enable the experimentalDecorators compiler option in your tsconfig.json:

1{ 2 "compilerOptions": { 3 "experimentalDecorators": true 4 } 5}

Class Decorators

Class decorators are applied to the constructor of the class and can be used to observe, modify, or replace a class definition.

Example: Singleton Pattern
1function Singleton<T extends { new (...args: any[]): {} }>(constructor: T) { 2 let instance: T; 3 4 return class extends constructor { 5 constructor(...args: any[]) { 6 if (instance) { 7 return instance; 8 } 9 super(...args); 10 instance = this; 11 } 12 }; 13}

This is a class decorator that implements the Singleton pattern. It returns a new class that extends the original constructor. The new class ensures only one instance is created by checking if an instance already exists before creating a new one.

1@Singleton 2class Database { 3 constructor(private readonly url: string) { 4 console.log(`Connecting to ${url}...`); 5 } 6}

This is the class we're applying the Singleton pattern to. It has a private constructor (to prevent direct instantiation) and a connect method

1const db1 = new Database('MySQL'); 2const db2 = new Database('PostgreSQL'); 3 4console.log(db1 === db2); // true 5db1.connect(); // "Connected to MySQL" 6db2.connect(); // "Connected to MySQL"

This demonstrates the Singleton behavior. Despite trying to create two instances with different names, we end up with the same instance. Both db1 and db2 refer to the same object (the first one created), which is why they both connect to "MySQL".

Method Decorators

Method decorators are applied to the Property Descriptor for the method, allowing you to observe, modify, or replace a method definition.

Example: Memoization

Memoization is an optimization technique where the result of a function is cached, so if the same inputs are passed again, the cached result is returned instead of recalculating it, improving performance by avoiding redundant computations.

Decorator Factory:
1function Memoize() { 2 return function ( 3 target: any, 4 propertyKey: string, 5 descriptor: PropertyDescriptor 6 ) { 7 // ... (implementation will be explained next) 8 }; 9}

This is a decorator factory, which is a function that returns the actual decorator. Think of it as a way to customize our decorator before applying it. The decorator function it returns is the real workhorse, taking three important parameters:

ParameterDescription
targetThe prototype of the class (for instance methods) or the constructor (for static methods).
propertyKeyThe name of the method being decorated.
descriptorAn object that describes the method being decorated.
Memoization Logic:
1const originalMethod = descriptor.value; 2const cache = new Map<string, any>(); 3 4descriptor.value = function (...args: any[]) { 5 const key = JSON.stringify(args); 6 if (cache.has(key)) { 7 return cache.get(key); 8 } 9 const result = originalMethod.apply(this, args); 10 cache.set(key, result); 11 return result; 12}; 13 14return descriptor;

This is the heart of our memoization technique. We begin by storing the original method and setting up a cache using a Map. Then, we replace the original method with a new function that does some clever work. It turns the arguments into a string key, checks if we've seen these arguments before in our cache, and if so, returns the stored result. If it's a new set of arguments, it calls the original method, saves the result for future use, and returns it. This way, we avoid unnecessary recalculations. Lastly, we return the modified descriptor, completing our memoization process.

Usage of the Memoize Decorator:
1class Calculator { 2 @Memoize() 3 fibonacci(n: number): number { 4 if (n <= 1) return n; 5 return this.fibonacci(n - 1) + this.fibonacci(n - 2); 6 } 7} 8 9const calculator = new Calculator(); 10console.log(calculator.fibonacci(10)); // 55 11console.log(calculator.fibonacci(10)); // 55 (cached result)

Property Decorators

Property decorators are powerful tools that let you enhance or alter how a class property behaves. They act like watchful guardians, allowing you to observe when a property is accessed or changed, and even modify its behavior. This can be incredibly useful for adding validation, logging, or transforming data automatically whenever the property is used.

Example: Validation
1function MinLength(length: number) { 2 return function (target: any, propertyKey: string) { 3 let value: string; 4 const getter = function () { 5 return value; 6 }; 7 const setter = function (newVal: string) { 8 if (newVal.length < length) { 9 throw new Error( 10 `${propertyKey} must be at least ${length} characters long.` 11 ); 12 } 13 value = newVal; 14 }; 15 Object.defineProperty(target, propertyKey, { 16 get: getter, 17 set: setter, 18 }); 19 }; 20}

This MinLength decorator ensures that the username property always has a minimum length, throwing an error if the requirement is not met.

Usage of the MinLength Decorator:
1class User { 2 @MinLength(5) 3 username: string; 4} 5 6const user = new User(); 7user.username = 'John'; // Error: username must be at least 5 characters long.

Conclusion

TypeScript decorators are a powerful feature that can significantly enhance your code's functionality and readability. From implementing design patterns like Singleton to adding validation and memoization, decorators offer a clean and reusable way to modify or extend your classes and methods. While they're still an experimental feature, their potential to simplify complex logic and promote better code organization is immense. As you've seen in these examples, decorators can be incredibly versatile, allowing you to write more expressive and maintainable TypeScript code.