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 own 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 in a simple way. 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;
}

You can easily 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 to change 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 a different behavior. 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 parameter. What is after @ is an expression that must return a function with the right signature. If means you can call a function. This allows to create 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 match 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 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 use 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 easily 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 shows the usage of a method decorator. I'd like to show the usage of a class decorator. A class decorator takes the constructor function in parameter and do things with it. For instance, you can call Object.seal to prevent the prototype to be changed. If you are writting 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 improve the readability of your code. In this post we've create 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.

Leave a reply