Aspect Oriented Programming in TypeScript
Aspect-Oriented Programming (AOP) addresses the problem of cross-cutting concerns, which would be any kind of code that is repeated in different methods and can't normally be completely refactored into its module, like with logging, caching, or validation. These system services are commonly referred to as cross-cutting concerns because they tend to cut across multiple components in a system.
AOP allows you to extract this kind of code, and inject it everywhere you need it without rewriting similar code. For instance, the following code contains logging, security, caching, and the actual code:
function sample(arg: string) {
console.log("sample: " + arg);
if(!isUserAuthenticated()) {
throw new Error("User is not authenticated");
}
if(cache.has(arg)) {
return cache.get(arg);
}
const result = 42; // TODO complex calculation
cache.set(arg, result);
return result;
}
With TypeScript, you can rewrite the method to:
@log
@authorize
@cache
function sample(arg: string) {
const result = 42;
return result;
}
The code is easier read the method without all the bloating code. The code will be automatically rewritten as the original code. Let's see how to do that using TypeScript using decorators.
#Decorators in TypeScript
A decorator is a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form @expression
, where expression
must evaluate to a function that will be called at runtime with information about the decorated declaration.
@sealed
class Sample {
@cache
@log(LogLevel.Verbose)
f() {
// code
}
}
At runtime, when you call f()
, it will actually call cache(log(f()))
. This allows changing the default behavior of the method f
. A decorator allows you to wrap the actual function with your cross-cutting concern code.
To write a decorator, you have to create a function that takes parameters and manipulate the descriptor. The parameters will depend of what you want to decorate: class, method, property, accessor, or a parameter. The typed declaration here-after should be enough, but you can jump to the documentation for more information: https://www.typescriptlang.org/docs/handbook/decorators.html.
Note: The composition of 2 functions is not commutative. Thus, the order of declaration of the decorator is very important. Swapping 2 decorators may lead to different behaviors. In the memoization example later in the post, you can try to swap @log
and @cache
to see the difference.
Before using a decorator, you must enable them in the configuration file tsconfig.json
:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
- class decorator
function sampleClassDecorator(constructor: Function) {
}
@sampleClassDecorator
class Sample {
}
- method decorators
function sampleMethodDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
}
class Sample {
@sampleMethodDecorator
f() {
}
}
- property decorators
function samplePropertyDecorator(target: Object, propertyKey: string | symbol) {
}
class Sample {
@samplePropertyDecorator
x: number;
}
- accessor decorators
function sampleAccessorDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
}
class Sample {
@sampleAccessorDecorator
get x() { return 42; }
}
- parameter decorators
function sampleParameterDecorator(target: any, propertyKey: string, parameterIndex: number) {
}
class Sample {
x(@sampleParameterDecorator str: string) { }
}
- Decorator factories or parametrize decorators
A decorator can have parameters. What is after @
is an expression that must return a function with the right signature. It means you can call a function. This allows creating a decorator with parameters. For instance, @configurable(false)
. To create a parametrize decorator, create a function with the desired parameters, and return a function that matches the signature of a decorator:
function sampleMethodDecorator(value: boolean) {
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
};
}
class Sample {
@sampleMethodDecorator(true) // call the function
f() {
}
}
In this post, we'll see how to log the parameters and return value of a method, use memoization, or automatically raise an event when a property is changed.
#Logging parameters and the return value of a method
This decorator logs the parameters and the return value. In a method decorator, the actual function is located in descriptor.value
. The idea is the replace decorator.value
with a wrapper function.
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// keep a reference to the original function
const originalValue = descriptor.value;
// Replace the original function with a wrapper
descriptor.value = function (...args: any[]) {
console.log(`=> ${propertyKey}(${args.join(", ")})`);
// Call the original function
var result = originalValue.apply(this, args);
console.log(`<= ${result}`);
return result;
}
}
Here's how to use the decorator:
class Sample {
@log
static factorial(n: number): number {
if (n <= 1) {
return 1;
}
return n * this.factorial(n - 1);
}
}
Sample.factorial(3);
#Caching result (Memoization)
Memoization is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.
The idea is again to wrap the method. Before executing the actual function, we check whether the cache contains the result for the given parameter. If it doesn't contain the result, let's compute it and store it into the cache. Instead of using an object {}
for caching results, you should use the Map
object (Map documentation). Indeed, a Map allows any kind of keys, not only strings. In our case, this is very useful.
For simplicity, I'll simplify the code for a method with one parameter (but you can improve it to support many arguments).
function memoization(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalValue = descriptor.value;
const cache = new Map<any, any>();
descriptor.value = function (arg: any) { // we only support one argument
if (cache.has(arg)) {
return cache.get(arg);
}
// call the original function
var result = originalValue.apply(this, [arg]);
// cache the result
cache.set(arg, result);
return result;
}
}
Here's the usage of the decorator. I also add the log
decorator to show when the original method is called.
class Sample {
@memoization
@log
static factorial(n: number): number {
if (n <= 1) {
return 1;
}
return n * this.factorial(n - 1);
}
}
console.log(`3! = ${Sample.factorial(3)}`);
console.log(`4! = ${Sample.factorial(4)}`);
The first call will trigger 3 factorial calls. But the second will call 4 * factorial(3)
and use the cache for factorial(3)
. So factorial(3)
is called only once thanks to the memoization.
#Sealed classes
The 2 previous examples show the usage of a method decorator. I'd like to show the usage of a class decorator. A class decorator takes the constructor function as parameter and does things with it. For instance, you can call Object.seal
to prevent the prototype to be changed. If you are writing a lib, this can be useful in some cases to ensure the user doesn't do unwanted things with your object.
From MDN:
The
Object.seal()
method seals an object, preventing new properties from being added to it and marking all existing properties as non-configurable. Values of present properties can still be changed as long as they are writable.
The decorator is very simple. It just needs to call Object.seal
on the constructor and its prototype:
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
Then, you can use it:
@sealed
class Sample {
factorial(n: number): number {
if (n <= 1) {
return 1;
}
return n * this.factorial(n - 1);
}
}
if you try to augment the object it will fail:
Sample.prototype.newMethod = function(a) { return a; };
console.log(Sample.prototype.newMethod); // "undefined"
#Conclusion
In TypeScript, the decorators allow to code similar behaviors only once and reuse them everywhere. This allow to reduce the number of line of code and also the number of potential bugs. It also improves the readability of your code. In this post we've created very simple decorators. If you look on the internet, you'll find lots of examples to handle validation, or logging the execution time. Angular also uses them a lot: @Inject
,@Component
, @Input
, @Output
, etc. Don't hesitate to write a comment with your ideas.
Do you have a question or a suggestion about this post? Contact me!