The definition of metaprogramming is really quite simple: programs that manipulate programs.
Therefore, parsers, compilers, interpreters, and domain-specific languages are actually meta programs. In general-purpose languages, we use metaprogramming for things like code generation and serializers and deserializers, but in this blog, I want to focus on aspect-oriented programming.
The idea for this blog came from a workshop I attended at Web Summer Camp, hosted by Daniel Ostrovsky. It got me really interested in metaprogramming in TypeScript, so I decided to dive into the topic.
Aspect-oriented programming (AOP) is a programming paradigm that specifically addresses cross-cutting concerns. These cross-cutting concerns are parts of a program that rely on or must affect many other parts of the system.
The most common such cases are logging, validation, error handling, and transaction management. AOP aims to separate these cross-cutting concerns from the core business logic.
In TypeScript, we can implement these aspects using method descriptors and decorators.
What are descriptors?
Descriptors in TypeScript are objects that provide metadata about properties, methods, and parameters of classes. This metadata allows developers to dynamically alter the behavior of these elements at runtime.
For instance, a descriptor object typically gives us information about whether a property is configurable (can be changed), enumerable (can be listed in a loop), writable (can be modified), and what its value is. For methods, descriptors can include details about getters and setters, enabling dynamic control over how properties are accessed and modified.
What are decorators?
A decorator is a special kind of declaration that can be attached to a class, method, accessor, property, or parameter.
Decorators are written in the form @expression, where the expression must evaluate to a function. This function is called at runtime and contains information about the element being decorated.
To create a decorator, you'll need to define a function that accepts parameters and manipulates the descriptor accordingly. The specific parameters will vary depending on what you intend to decorate.
The order in which you declare decorators matters because the composition of two functions is not commutative. This means that swapping two decorators can result in different behavior.
Before using decorators, ensure they are enabled in your tsconfig.json configuration file:
How descriptors and decorators work together
Logging
Suppose you are in the process of creating a new method and want to log what is happening in your application. You might add a couple of console logs here and there like this:
Unfortunately, this becomes a "mess" to read as console.log statements take up space. We can avoid this by creating a separate function for logging:
Now, we can just add this as a decorator to our function. This is called a function decorator:
We have achieved the same functionality without actually touching our original function, and therefore, our business logic is completely separate from our logging aspect.
Constructor
A fun way to use decorators is to expand a class by hijacking the constructor method and adding a new field to the class.
This way we extend the Monster class to also have a power attribute.
Memoization
Memoization is an optimization technique primarily used to enhance the performance of computer programs. It works by storing the results of expensive function calls and returning the cached result when the same inputs are encountered again.
The process involves wrapping the method so that before the function executes, the cache is checked to see if the result for the given parameters already exists. If the result isn’t cached, it’s computed and then stored for future use. Instead of using a simple object {} for caching results, it's recommended to use the Map object (see the Map documentation). The Map allows for any type of key, not just strings, making it particularly useful in this scenario.
Now just add @memoization to the multiplier method.
In the first call, the multiplier is invoked and it calculates the xp multiplier. However, during the second call, the result for multiplier is retrieved from the cache, thanks to memoization, so the function doesn't need to be computed again.
Callback functions
Callback functions are a powerful concept that allows us to pass a function as an argument to another function, enabling more dynamic and flexible code execution. This pattern is especially useful when you want to abstract away certain behaviors and make your code more modular.
Let's consider a scenario in a fantasy game where a Npc greets a player differently whether they are an Elf or a Gnome perhaps. We want this greeting behavior to be flexible, allowing different NPCs to greet the player differently. Well we can achieve this using a callback function in TypeScript:
This is a higher-order function (a function that takes another function as an argument), you can create more complex and versatile functionality. For instance, you might want to add additional processing to the greeting (e.g., logging, modifying the message, or handling special cases) inside npcGreeting without altering the greetPlayer method itself.
Sealing a Class
In this example, we will use a class decorator to seal the Monster class, preventing any modifications to its structure, such as adding new properties or methods. This is particularly useful in situations where you want to ensure that the class's definition remains unchanged.
Decorator definition
Using the decorator with the Monster Class
The sealed decorator calls Object.seal() on the Monster class constructor and its prototype. This ensures that no new properties or methods can be added to the Monster class after it has been sealed.
The Monster class still includes a health property and a takeDamage method that reduces the monster's health by a specified amount of damage. But after applying the sealed decorator, any attempt to add new methods or properties to the Monster class will fail. In this case, trying to add a newMethod to Monster.prototype will result in undefined.
This can be a powerful tool for ensuring the integrity of your classes in a library or application.
Conclusion
In TypeScript, decorators provide a powerful mechanism for encapsulating common behaviors and applying them consistently across your codebase. By allowing you to define and reuse functionality in a modular way, decorators help reduce code duplication, minimize potential bugs, and enhance code readability.
In this blog post, we explored some basic examples of decorators, such as memoization, logging, and sealing classes. These examples demonstrate how decorators can simplify your code and enforce consistency.
As you continue to explore decorators, you'll discover even more advanced use cases, such as input validation, execution time tracking, and beyond, enabling you to write cleaner, more maintainable TypeScript code.