TypeScript. Декораторы

С введением классов в TypeScript и ES6 в настоящее время существуют определенные сценарии, которые требуют дополнительных возможностей для поддержки аннотирования или модификации классов и членов класса. Декораторы позволяют, как добавить аннотации, так и использовать синтаксис метапрограммирования при объявлении классов и их членов. Декораторы предлагают удобный декларативный синтаксис для изменения формы объявления класса.

Декораторы еще не вошли в стандарт EcmaScript и на момент написания статьи находятся на стадии stage 1 proposal. Однако, несмотря на то, что они являются экспериментальной функцией и в дальнейшем возможны изменения, декораторы активно используются в Angular2. Ангуларовские @NgModule, @Component, @Injectable - это не что иное, как эти самые декораторы. Поэтому данную тему я решил не откладывать до момента официального вхождения в стандарт. Попробую разобраться хотя бы в том, как они работают, чтобы в дальнейшем было проще с изучением Angular2.

Для того чтобы включить поддержку декораторов в TS, нужно добавить опцию experimentalDecorators в командной строке:

tsc --target ES5 --experimentalDecorators

либо в файле tsconfig.json:

{
	"compilerOptions": {
		"target": "ES5",
		"experimentalDecorators": true
	}
}

Декоратор

Декоратор представляет собой объявление вида @expression, где expression - функция, которая будет вызвана во время выполнения с информацией о декорируемом элементе. Декорировать можно классы, методы, аксессоры, свойства или параметры.

Например, для декоратора @sealed (запечатанный) можно было бы написать функцию sealed:

function sealed(target) {
    // запечатываем target
}

Фабрика декораторов

Если мы хотим настроить, как декоратор применяется к элементу, мы можем написать фабрику декораторов. Это просто функция, которая может принимать некоторые параметры и возвращать декоратор.

Пример фабрики декораторов:

function color(value: string) { // это фабрика
	return function (target) { // это декоратор
		// делаем что-то с  'target' и 'value'...
	}
}

Композиция декораторов

Несколько декораторов могут быть применены к объявлению следующим образом:

  • в одну строку:

      @f @g x
    
  • в несколько строк:

      @f
      @g
      x
    

Несколько декораторов вместе будут работать подобно функциям в математике, т.е. так: f(g(x)).

Декораторы классов

Декоратор класса объявляется непосредственно перед объявлением самого декорируемого класса. Применяется к конструктору класса и может быть использован для наблюдения, модификации или замены определения класса.

Выражение для декоратора класса будет вызываться во время выполнения как функция с конструктором декорируемого класса в качестве единственного аргумента.

Пример декоратора класса (@sealed) для класса Greeter:

@sealed
class Greeter {
	greeting: string;
	constructor(message: string) {
		this.greeting = message;
	}
	greet() {
		return "Hello, " + this.greeting;
	}
}

Для декоратора sealed можем записать следующую функцию:

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

Когда декоратор @sealed будет выполнен, он запечатает и конструктор, и прототип класса Greeter.

Декораторы методов

Декоратор метода объявляется непосредственно перед самим методом. Декоратор применяется на дескрипторе свойства для данного метода и может быть использован для наблюдения, модификации или замены определения метода.

Выражение для декоратора метода будет вызвано как функция с тремя аргументами:

  1. Либо функция-конструктор класса для статического метода, либо прототип класса.

  2. Название метода.

  3. Дескриптор свойства для данного метода.

Примечание: если версия целевого скрипта ниже ES5, то дескриптор свойства будет undefined.

Пример использования декоратора @enumerable, который включает или отключает доступность метода при перечислении свойств объекта:

class Greeter {
	greeting: string;
	constructor(message: string) {
		this.greeting = message;
	}

	@enumerable(false)
	greet() {
		return "Hello, " + this.greeting;
	}
}

Реализация фабрики для декоратора enumerable:

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

Декораторы аксессоров

Декоратор аксессора объявляется непосредственно перед объявлением самого аксессора. Декоратор применяется на дескрипторе свойства для данного аксессора и может быть использован для наблюдения, модификации или замены его определения.

TypeScript не позволяет применять декоратор и к геттеру, и к сеттеру. Декоратор должен быть объявлен для первого аксессора, указанного в коде. Это происходит потому, что декоратор применяется для дескриптора свойства, которое содержит и get, и set.

Выражение для декоратора аксессора будет вызвано как функция с тремя аргументами:

  1. Либо функция-конструктор класса для статического члена, либо прототип класса.

  2. Название члена.

  3. Дескриптор свойства для данного члена.

Примечание: если версия целевого скрипта ниже ES5, то дескриптор свойства будет undefined.

Если декоратор аксессора возвращает значение, то оно будет использовано как дескриптор свойства для данного члена.

В следующем примере декоратор аксессоров @configurable применяется для членов класса Point:

class Point {
	private _x: number;
	private _y: number;
	constructor(x: number, y: number) {
		this._x = x;
		this._y = y;
	}

	@configurable(false)
	get x() { return this._x; }

	@configurable(false)
	get y() { return this._y; }
}

Реализация фабрики для декоратора configurable:

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

Декораторы свойств

Все то же самое. Объявляется непосредственно перед объявлением свойства. Выражение для декоратора свойства будет вызвано как функция с двумя аргументами:

  1. Либо функция-конструктор класса для статического члена, либо прототип класса.

  2. Название свойства.

Если декоратор свойства возвращает значение, то оно будет использовано как дескриптор.

Примечание: если версия целевого скрипта ниже ES5, то возвращаемое значение будет проигнорировано.

Можно использовать декоратор для сохранения метаданных о свойстве класса:

class Greeter {
	@format("Hello, %s")
	greeting: string;

	constructor(message: string) {
		this.greeting = message;
	}
	greet() {
		let formatString = getFormat(this, "greeting");
		return formatString.replace("%s", this.greeting);
	}
}

Мы можем написать декоратор @format и функцию getFormat следующим образом:

import "reflect-metadata";

const formatMetadataKey = Symbol("format");

function format(formatString: string) {
	return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
	return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}

Здесь @format("Hello, %s") - фабрика декораторов. Когда декоратор вызывается, он добавляет запись метаданных для свойства с использованием функции Reflect.metadata из библиотеки reflect-metadata (ниже про нее будет написано). Когда вызывается функция getFormat, она считывает метаданные и возвращает требуемый формат.

Декораторы параметров

Объявляется непосредственно перед объявлением параметра. Применяется для функции-конструктора класса или для метода.

Выражение для декоратора параметра будет вызвано как функция с тремя аргументами:

  1. Либо функция-конструктор класса для статического члена, либо прототип класса.

  2. Название члена.

  3. Порядковый номер параметра в списке аргументов функции.

Декораторы применяется только для наблюдения за параметром, объявленным в методе.
Значение, возвращаемое декоратором параметра, будет проигнорировано.

В следующем примере декоратор параметра @required применяется к параметру name функции greet:

class Greeter {
	greeting: string;

	constructor(message: string) {
		this.greeting = message;
	}

	@validate
	greet(@required name: string) {
		return "Hello " + name + ", " + this.greeting;
	}
}

Декораторы @required и @validate можно описать следующим образом:

import "reflect-metadata";

const requiredMetadataKey = Symbol("required");

function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
	let existingRequiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
	existingRequiredParameters.push(parameterIndex);
	Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}

function validate(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
	let method = descriptor.value;
	descriptor.value = function () {
		let requiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
		if (requiredParameters) {
			for (let parameterIndex of requiredParameters) {
				if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined) {
					throw new Error("Missing required argument.");
				}
			}
		}

		return method.apply(this, arguments);
	}
}

Декоратор @required добавляет запись метаданных, которая помечает параметр как обязательный. Декоратор @validate оборачивает метод greet в функцию, которая предварительно выполняет валидацию аргументов. В данном примере также требуется установка дополнительной библиотеки reflect-metadata, о которой речь пойдет чуть ниже.

Метаданные

В некоторых примерах была использована библиотека reflect-metadata, которая является полифиллом для эксперементального metadata API. Библиотека не является частью стандарта ECMAScript (JavaScript). Однако, как только декораторы войдут в стандарт, эти расширения будут предложены для принятия.

Установить библиотеку можно с помощью npm:

npm i reflect-metadata --save 

Для того чтобы включить поддержку метаданных в TypeScript нужно добавить опцию emitDecoratorMetadata в консоли:

tsc --target ES5 --experimentalDecorators --emitDecoratorMetadata

либо в файле tsconfig.json:

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

Когда библиотека reflect-metadata импортирована, дополнительная design-time информация о типах становится доступной в рантайме.

Мы можем увидеть в действии это в следующем примере:

import "reflect-metadata";

class Point {
	x: number;
	y: number;
}

class Line {
	private _p0: Point;
	private _p1: Point;

	@validate
	set p0(value: Point) { this._p0 = value; }
	get p0() { return this._p0; }

	@validate
	set p1(value: Point) { this._p1 = value; }
	get p1() { return this._p1; }
}

function validate<T>(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<T>) {
	let set = descriptor.set;
	descriptor.set = function (value: T) {
		let type = Reflect.getMetadata("design:type", target, propertyKey);
		if (!(value instanceof type)) {
			throw new TypeError("Invalid type.");
		}
	}
}

Компилятор TypeScript внедрит информацию о типе с помощью декоратора @Reflect.metadata. Вот эквивалентный код класса:

class Line {
	private _p0: Point;
	private _p1: Point;

	@validate
	@Reflect.metadata("design:type", Point)
	set p0(value: Point) { this._p0 = value; }
	get p0() { return this._p0; }

	@validate
	@Reflect.metadata("design:type", Point)
	set p1(value: Point) { this._p1 = value; }
	get p1() { return this._p1; }
}

Уф. Довольно сложная тема. Поэтому сильно глубоко проникнуться не получилось. Получил лишь легкое понимание принципа работы декораторов. Соответственно и статья вышла обычным сухим переводом официальной документации. Если у кого-то есть замечания, предложения или более доступные для понимания объяснения и примеры по данной теме, то буду рад комментариям. Спасибо за понимание.

P. S. Нашел на хабре добротную статью про декораторы в TypeScript с интересными примерами. Советую к прочтению. Да прибудет с вами Сила ;)