Aspect Oriented Programming in TypeScript

 
 
  • Gérald Barré

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:

TypeScript
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:

TypeScript
@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.

TypeScript
@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:

JSON
{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}
  • class decorator
TypeScript
function sampleClassDecorator(constructor: Function) {
}

@sampleClassDecorator
class Sample {
}
  • method decorators
TypeScript
function sampleMethodDecorator(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
}

class Sample {
    @sampleMethodDecorator
    f() {
    }
}
  • property decorators
TypeScript
function samplePropertyDecorator(target: Object, propertyKey: string | symbol) {
}

class Sample {
    @samplePropertyDecorator
    x: number;
}
  • accessor decorators
TypeScript
function sampleAccessorDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
}

class Sample {
    @sampleAccessorDecorator
    get x() { return 42; }
}
  • parameter decorators
TypeScript
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:

TypeScript
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.

TypeScript
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:

TypeScript
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).

TypeScript
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.

TypeScript
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:

TypeScript
function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

Then, you can use it:

TypeScript
@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:

TypeScript
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!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub