Dekorujemy TypeScript

Opublikowane przez lewy w dniu

TypeScript to taki fajny JavaScript i daje nam trochę więcej niż sam „król” przeglądarek. Jedną z tych fajnych rzeczy (choć nie każdy potwierdzi moje zdanie) które mamy w TypeScript a nie mamy w JavaScript są dekoratory.

Dekoratory to typy deklaracji, które mogą być używane z klasami, metodami klas, akcesorami (?, setter/getter) w klasach, własnościami klas lub parametrami metod w klasach.
A tak po polskiemu to po prostu funkcje które wywoływane są w runtime’ie wraz z wymienionymi wyżej elementami.

Dekoratorów używamy za pomocą zapisu @NazwaDekoratora gdzie NazwaDekoratora to nasza funkcja która zostanie wywołana wraz z dekorowanym elementem i do której zostaną przekazane specyficzne dane, jakie to zależy od dekoratora i opisane zostanie później.

Jeszcze jedna istotna sprawa. Dekoratory są „eksperymentalnym” elementem języka i aby z nich korzystać musimy o tym poinformować nasz transpilator TypeScript. Jak to zrobić? Są na to dwa sposoby.

Poprzez flagę

tsc --experimentalDecorators

Plik tsconfig.json

{
    "compilerOptions": {
        "experimentalDecorators": true
    }
}

Ok, jak już wiemy czym są (tak ogólnie) dekoratory i jak móc z nich korzystać to teraz zobaczmy jak je napisać.

Pierwszym popularnym zastosowaniem dekoratorów będą dekoratory klas. Należy wiedzieć, że dekorator klasy tak naprawdę wiązany jest z konstruktorem tej klasy i wraz z nim uruchamiany. Nasz dekorator (funkcja) jako parametr przyjmie konstruktor dekorowanej klasy.

function addMyMagicFunction(constructor: Function) {
    console.log(`You created instance of ${constructor.name} with magicFunction!`)
    constructor.prototype.magicFunction = function() {
        console.log('You called magic function!');
    }
}

Powyższy kod nie robi nic specjalnego, po za tym że wyświetla nam komunikat przy tworzeniu nowej instancji dekorowanej klasy i dodaje do tej klasy nową metodę magicFunction. Teraz możemy użyć naszego dekoratora.

@addMyMagicFunction
class MyClass {
}

const x = new MyClass();
x.magicFunction();

Powyższy kod po transpilacji i uruchomieniu pokaże nam w konsoli następujący kod

You created instance of MyClass with magicFunction!
You called magic function!

Innym ciekawym zastosowaniem dekoratora dla klasy może być chęć rozszerzenia dekorowanej klasy. Aby to uzyskać funkcja dekoratora musi zwracać nowa klasę. Możemy to wykorzystać np. do dodania jakiś specyficznych metod.

function toString<T extends {new(...args: any[])}>(constructor: T) {
    return class extends constructor {
        toString() {
            return `${constructor.name} ${JSON.stringify(this)}`;
        }
    }
}

@toString
class MyClass {
    myParam = 'Hello';
}

const x = new MyClass();
console.log(x.toString());

// output:
// MyClass {"myParam":"Hello"}

Ok, mając za sobą dekoratory dla klas przejdźmy do dekoratorów metod.
Ponownie piszemy funkcję, która tym razem powiązana jest z metodą którą dekoruje, jednak w przeciwieństwie do dekoratora klasy tym razem nasza funkcja uruchamiana jest z trzema parametrami,
pierwszym z nich jest informacja o klasie w której metoda się znajduje (funkcja konstruktora klasy dla metod statycznych albo prototyp klasy w przypadku metody która nie jest statyczna),
drugi parametr to nazwa dekorowanej metody,
trzeci to PropertyDescriptor metody, dzięki któremu np. możemy zmienić zachowanie metody.

Napisanie następującego kodu, nie spowoduje porządanego efektu

function logger(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log(`${propertyKey} called!`);
}

Celem jest logowanie wywołań metody

class MyClass {
    @logger
    foo() {
        console.log('foo');
    }
}

const x = new MyClass();
x.foo();
x.foo();

Jednak uruchamiając powyższy kod zobaczymy coś takiego

foo called!
foo
foo

Co oznacza, że funkcja naszego dekoratora uruchomiła się raz, w momencie powoływania do życia instancji klasy a nie tak jak byśmy tego oczekiwali, czyli przy wywołaniu metody. Tak napisaną funkcję możemy np. wykorzystać do zmiany zachowania naszej funkcji aby nie było możliwe iterowanie po niej przy pomocy konstrukcji for … in …

function enumerableFalse(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = false;
}

class MyClass {
    @enumerableFalse
    foo() {
        console.log('foo');
    }
}

Jeżeli chcielibyśmy uzyskać oczekiwany wcześniej efekt powinniśmy przeciążyć własność value z naszego PropertyDescriptora

function logger(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function () {
        console.log(`${propertyKey} called!`);
        return original.apply(this, arguments);
    }
}

Tak naprawdę dekorator dla metody jest głównym powodem powstania tego wpisu. Czasem potrzebuję debuggera kolejności wywołania metod, a gdy nie mam zestawionego odpowiednio środowiska do debuggowania kodu, to najprostszym sposobem jest console.log(‚metoda foo’) i aby uniknąć pisania tego wiele razy stworzyłem biblioteczkę https://www.npmjs.com/package/logcall kod dostępny tutaj: https://github.com/lewyino/LogCall

Dekoratory dla akcesorów (getterów i setterów) działają, wyglądają i piszemy je dokładnie tak samo jak dekoratory dla metod. Jest tutaj jednak jedna istotna uwaga, dekorator nie może być użyty zarówno do gettera jak i settera o tej samej nazwie, może być użyty tylko do jednego z nich, pierwszego w kolejności zapisu w klasie.

class MyClass {
    @enumerableFalse
    get foo() {
        console.log('getter foo');
        return 'foo'
    }
}

Kolejny na liście typ dekoratora to dekorator dla właściwości klasy. Funkcja takiego dekoratora przyjmuje dwa parametry,
pierwszy tak jak w przypadku metod to informacje o klasie (konstruktor lub prototype),
drugi to nazwa dekorowanego property.

Jak widzimy, możliwości są ograniczone w stosunku do dekoratorów metod, ale nadal mamy szanse np. obserwować że dla danej klasy zostanie dodana własność i można coś z tym zrobić, np. sprawię żę moja własność będzie mogła mieć tylko raz przypisaną wartość, której już nie zmienimy.

function immutable(target: any, propertyKey: string) {
    let _var = target[propertyKey];
    Object.defineProperty(target, propertyKey, {
        get() {
            return _var;
        },
        set(val: any) {
            if (_var === undefined) {
                _var = val;
            }
        },
    });
}

class MyClass {
    @immutable
    public foo = 'foo';
}

const x = new MyClass();
x.foo = 'foo2';
console.log(x.foo);

// output:
// foo

Powyższy kod zapisany w ten sposób powoduje, że własność foo jest tak naprawdę readonly, ktoś może powiedzieć, że przecież jest słowo kluczowe readonly w TypeScript, ale ono działa tak jak byśmy tego chcieli tylko na poziomie transpilacji, później jako kod JavaScript już nie działa poprawnie i można wspomnianą wartość edytować dowolnie

class MyClass {
    @immutable
    public foo = 'foo';
    readonly bar = 'bar'
}

const x = new MyClass();
x.foo = 'foo2';
x.bar = 'bar2';
console.log(x.foo);
console.log(x.bar);

// output
// foo
// bar2

Ostatni typ dekoratora to dekorator dla argumentów metod. Funkcja odpowiadająca takiemu dekoratorowi przyjmuje trzy parametry, pierwsze dwa są takie same jak dla właściwości klasy,
pierwszy to informacje o klasie (konstruktor lub prototype),
drugi to nazwa metody w której dekorowaliśmy argument,
trzeci to indeks dekorowanego argument.

Dzięki tym informacjom możemy sobie zapisać który argument został dekoratorem oznaczony a następnie wykorzystać tą informację przy wywołaniu metody. Bardzo często stosuje się to do walidacje parametrów. Mój dekorator natomiast zamieni oznaczone argumenty na duże litery.

function uppercase(target: any, propertyKey: string, index: number) {
    target[propertyKey + 'not_null'] = target[propertyKey + 'not_null'] || [];
    target[propertyKey + 'not_null'].push(index);
}

Sam dekorator dla argumentu jeszcze nic sensownego nie robi, stworzyłem tablicę którą jednoznacznie identyfikuję za pomocą nazwy metody i jakimś moim dodatkowym stringiem i przechowuję w niej indeksy argumentów oznaczonych dekoratorem. Dopiero w połączeniu z dekoratorem dla metody w którym zapisane wcześniej dane wykorzystamy dostajemy użyteczną funkcjonalność.

function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function() {
        const arr = target[propertyKey + 'not_null'];
        let args = Array.from(arguments);
        if (arr) {
            args = args.map((arg, index) => arr.indexOf(index) > -1 ? arg.toUpperCase() : arg);
        }
        return original.apply(this, args);
    }
}

I teraz mamy w pełni funkcjonalną wartość dekoratora dla argumentów metody.

class MyClass {
    @validate
    public foo(@uppercase arg1: string, arg2: string) {
        console.log(arg1);
        console.log(arg2);
    }
}

const x = new MyClass();
x.foo('foo', 'foo2');

// output
// FOO
// foo2

Jak widać dekoratory można wykorzystać na wiele sposobów, ich możliwości są duże, a znajomość bardzo często się przydaje.

Jeszcze jedna informacja, możemy tworzyć coś takiego jak fabryki dekoratorów, czyli wykorzystywać w dekoratorach jakieś wcześniej predefiniowane wartości, np. zamiast tworzyć konkurencyjny dla wcześniej zdefiniowanego dekoratora enumerableFalse dekorator enumerableTrue, możemy nasz dekorator opakować inną funkcją przyjmującą parametr i zwrócić funkcję dekoratora.

function enumerable(enumerable: boolean) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = enumerable;
    }
}

class MyClass {
    @enumerable(false)
    foo() {
        console.log('foo');
    }
    @enumerable(true)
    bar() {
        console.log('bar');
    }
}