Mighty @Decorators in Typescript - what could we get out of it?

Mighty @Decorators in Typescript - what could we get out of it?

Every Angular developer has seen decorators in Typescript code. We use it to define Module (hopefully, will not use modules in future but anyway), to configure dependency injection or component properties. In other words, We use it to put some additional information, or metadata, for the compiler. Angular is just one sample of that. As .NET developer in the past I see a lot of similarity between TS decorators and .NET Attributes. Needless to remind about modern NestJS framework, which is also built atop of decorators idea to make a step into readable declarative programming. But how does it work? Can we use it to make our regular code better?

Agenda

  • Decorator samples
  • Angular Decorator - work with metadata
  • In general - what is Typescript decorator?
  • Types of decorators
  • Function decorator
  • Logging, Caching or other extra behavior for function
  • Class decorator
  • Dependency Injection Decorator
  • Property decorator
  • Validation of object properties with decorators
  • Some existing libraries to get decorators usage (class-validators)
  • Sample of my NPM package for runtime type safety

Samples of decorators

Angular is pretty famous for its intense usage of decorators. Before we dived in, let's just quickly recap some sample of usages in Angular:

  • for Module declaration
@NgModule({
  imports: [
    CommonModule,
  ],
  exports: [
  ],
})
export class NbThemeModule {}
NgModule declaration

- for component declaration

@Component({
  selector: 'nb-card-header',
  template: `<ng-content></ng-content>`,
})
export class NbCardHeaderComponent {}

First of all, to make decorators work you need to turn it on in tsconfig.json, because it's kind of experimental feature:

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es2017",
  },
}

General Information

According to documentation of 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.

In other words, decorator is a way how we can add some additional behavior to class, function, property or parameter. It is also a programming paradigm called meta-programming, or declarative programming.

But the most important part is that decorator is just a function. When you use it, in runtime you will first have the decorator function running, and only then the initial object behavior executed. If this is function decorator, it means that firstly decorator gets executed, and then (if there is such code in decorator), inner (wrapped) function gets executed. If you have several decorators, they will be executed one by one, from the top to the bottom.

Decorators for Functions

Let's start from the most obvious case - function decorator. Typescript defines is like this:

declare type MethodDecorator = 
	<T>(
    	target: Object, 
        propertyKey: string | symbol, 
        descriptor: TypedPropertyDescriptor<T>) 
=> TypedPropertyDescriptor<T> | void;

It's a function, which takes several arguments - an object, where this function was called, name of a function and its descriptor.

interface TypedPropertyDescriptor<T> {
    enumerable?: boolean;
    configurable?: boolean;
    writable?: boolean;
    value?: T;
    get?: () => T;
    set?: (value: T) => void;
}

Remember, that it's not your code which calls decorator. We will see it in compiled JS of the next sample.

One of the simple and useful scenarios for function decorator is performance measurement.

class TestServiceDeco {

	@LogTime()
	function testLogging() {
    	...
    }
}
method, property and parameters decorators can be used only inside some class. You cannot apply method decorator to a function which is just written in some file without a class

Decorator for this case looks like:

function LogTime() {
    return (target: Object, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) => {
        const method = descriptor.value;
        descriptor.value = function(...args) {
            console.time(propertyName || 'LogTime');
            const result = method.apply(this, args);
            console.timeEnd(propertyName || 'LogTime');
            return result;
        };
    };
}

Decorator is a function, which returns other function of special type. Inner one should have arguments such as target - for whom we called the initial function, propertyName - name of a function, and the last one - it's descriptor. Function Descriptor allows to modify its definition by suppling new value to descriptor.value. In fact, we replace initial function definition with ours, where we first do preparation logic (start time tracking), then call the function, than do post-work for performance measurement. And finally return expected result of inner function.

Compiled Javascript code of this decorator looks like this:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function LogTime() {
    return (target, propertyName, descriptor) => {
        const method = descriptor.value;
        descriptor.value = function (...args) {
            console.time(propertyName || 'LogTime');
            const result = method.apply(this, args);
            console.timeEnd(propertyName || 'LogTime');
            return result;
        };
    };
}
exports.LogTime = LogTime;

This looks mostly the same as Typescript code. But the place where we used decorator is different in Javascript.

Object.defineProperty(exports, "__esModule", { value: true });
const log_time_decorator_1 = require("../src/samples/log-time.decorator");
class TestServiceDeco {
    testLogging() {
...    }
}
__decorate([
    log_time_decorator_1.LogTime(),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", []),
    __metadata("design:returntype", void 0)
], TestServiceDeco.prototype, "testLogging", null);

Here we can observe system call __decorate, where our function decorator is passed, as well as other metadata.

Class Decorator

Let's review how we can implement class decorator to work with metadata of a type. Imagine we need to create own lightweight version of dependency injection container. So that we can mark own classes like this:

@CustomBehavior({
    singleton: false,
    tags: ['singleton', 'test', 'service'],
})
class TestServiceDeco {
    constructor() {
        console.log('TestServiceDeco ctor');
    }
}

Formally, class decorator is defined like this:

declare type ClassDecorator = 
	<TFunction extends Function>(target: TFunction) 
    => TFunction | void;

So our own decorator should have the code similar to:

import 'reflect-metadata';

interface Metadata {
    singleton?: boolean;
    tags?: string[];
}

function CustomBehavior(metadata: Metadata) {
    return function(ctor: Function) {
        Reflect.defineMetadata('metadataKey', metadata, ctor);
    }
}

We have defined interface to bring structure into own metadata. We need to know if this is a singleton class or not, plus I would like to have some custom tags for class, so that we can adjust behavior for some cases.

You can see here couple of important things.

  • Target argument now is constructor of a class, variable ctor in a sample
  • We added usage of reflect-metadata

Reflect-metadata has the same meaning as  metadata in other languages - it is additional information about types, which can be used in a runtime to perform some custom behavior. For our case, dependency injection container, it can be following:

import 'reflect-metadata';

const instancesMap: Map<Object, Object> = new Map<Object, Object>();

function getInstance<T>(tType: new () => T): T {
    let metadata = Reflect.getMetadata('metadataKey', tType) as Metadata;
    if (metadata.singleton) {
        if (!instancesMap.has(tType)) {
            instancesMap.set(tType, new tType());
        }
        return instancesMap.get(tType) as T;
    } else {
        return new tType() as T;
    }
}
  • type, instance of which we need to create, is passed into the function as tType argument
  • With call to Reflect.getMetadata we can get metadata, which we set in code. In our case we need to know value of singleton property of metadata
  • we use as Metadata after call to Reflect.getMetadata because by design it returns any
  • to use constructor of provided tType, but in order to pass Typescript validation, we set restriction to this type as: tType: new () => T, letting TS know, that we expect to have here only classes with available default constructor
  • finally, we defined Map to store instances of our types, and work with it

Voila, with just several lines of code we created decorator to define some useful metadata and simple dependency injection container. So we can use it everywhere in the application when we need to get instance of some class, but how this instance is resolved - up to metadata!

I didn't add sample of compiled Javascript code, it looks mostly the same as previous samples. But the key difference is that it will be executed only once during code interpretation by node or browser.

Property Decorators

Other useful area where we can find decorators is related to class properties. The whole range of tasks can be handled by such decorators. Let's stick to rather important one - data validation. Imagine we have class Person and the field Age, which by rules of a system should be between 18 and 60. Let's do this validation with decorator.

class Person {
    @Age(18, 60)
    age: number;
}

By formal definition property decorator is

declare type PropertyDecorator = 
	(target: Object, propertyKey: string | symbol) => void;

So our validation decorator can look like this

import 'reflect-metadata';

function Age(from: number, to: number) {
    return function (object: Object, propertyName: string) {
        const metadata = {
            propertyName: propertyName,
            range: { from, to },
        };
        Reflect.defineMetadata(`validationMetadata_${propertyName}`, metadata, object.constructor);
    };
}

As you might notice, there is no actual validation happening here. That's because in property decorator itself we can only set some metadata. Decorator code will be executed during the first run of the code, where class is defined. Before any constructor has been executed. Let's check out compiled javascript:

class Person {
}
__decorate([
    age_decorator_1.Age(18, 60),
    __metadata("design:type", Number)
], Person.prototype, "age", void 0);

You can easily see class is immediately followed by call to __decorate function, which executed our decorator function for the property. So the main reason we use decorators - readable, declarative approach to define validation rules in the same place where we define class.

Getting back to validation, it should be done in separate function, which uses provided metadata:

function validate<T>(object: T) {
    const properties = Object.getOwnPropertyNames(object);
    properties.forEach(propertyName => {
        let metadata = Reflect.getMetadata(metaKey + propertyName, object.constructor);
        if (metadata && metadata.range) {
            const value = object[metadata.propertyName];
            if (value < metadata.range.from || value > metadata.range.to) {
                throw new Error('Validation failed');
            }
        }
    });
}

This sample takes care only of particular Age decorator. Production ready approach, of course, should verify all implemented validation rules.

const person = new Person();
person.age = 40;
validate(person);
// > validation passed

person.age = 16;
validate(person);
// > validation error

Parameter Decorator

If you have read to this place, I think you have received enough information and samples to try implementing own ParameterDecorator . Let's make it as optional but potentially useful home task.

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;


Existing Libraries

Class-Validator

This library uses decorators approach to define validation rules for classes and it has quite a number of validation options

export class Post {

    @Length(10, 20)
    title: string;

    @IsInt()
    @Min(0)
    @Max(10)
    rating: number;

    @IsEmail()
    email: string;
}

...

validate(object).then(errors => { // array of validation errors
    if (errors.length > 0) {
        console.log("validation failed. errors: ", errors);
    } else {
        console.log("validation succeed");
    }
});

All details can be found in their repository.

The interesting fact is that this library is used by default in NestJS framework when you use @UsePipes(new ValidationPipe()) to validate incoming HTTP requests.

ts-stronger-types

This is library I created for experimental purposes, you can read more about it in other article - Runtime Type Safety in Typescript. The main idea is to validate just a type of function arguments - is it number, or string, based on standard metadata and reflection of Typescript.

ts-stronger-types
Runtime checking of types and integrity for Typescript projects

Links

Conclusion

Typescript has big potential and number of instruments to create readable and supportable code, it can be used to code in several paradigms, including meta-programming or declarative programming. Decorators, even they are still experimental language feature, give the whole new slice of capabilities to make code more readable and scalable - validation use case, performance measurement, logging, objects behavior adjustment, custom compilation process ... Frameworks like Angular or NestJS are quite good examples of how useful decorators can be. Understanding of this feature will help you to write better programs!

Write code, Make it better, cleaner, more beautiful, test it and enjoy it!