В прошлой статье, посвящённой теме декораторов в JavaScript, мы познакомились с самой концепцией декораторов, выяснили для чего они могут быть полезны и как реализованы на сегодняшний день в JavaScript. Сегодня мы продолжим разбираться с декораторами в TypeScript. Итак, поехали!
Кратко про TypeScript и декораторы
На сегодняшний день TypeScript прочно обосновался в качестве альтернативы JavaScript для написания современных web приложений. Он всё больше используется не только в клиентских фреймворках (Angular, React, Vue и т.д.), но и на сервере (Nest.js, Loopback).
Плюс ко всему, так как TypeScript изначально является надмножеством JavaScript, мы можем постепенно переносить кодовую базу существующих legacy приложений на TypeScript. Однако, неважно, пишем ли мы новое приложение или переносим старое, есть ряд особенностей TypeScript, которые отличаются от того, что есть в JavaScript и на которые многие фреймворки завязываются довольно сильно. Как раз одной из таких особенностей и являются декораторы. Так же как и в JavaScript реализация декораторов в TypeScript основывается на черновике TC39 и эта фича в TypeScript является экспериментальной. Плюс ко всему есть существенные отличия в реализации по сравнению с Babel-плагином для JavaScript. На самом деле плагинов тоже есть несколько, а ещё в самом плагине для Babel есть поддержка legacy-реализации, которая изначально была в черновике. И вот как раз текущая реализация для TypeScript больше похожа на legacy-версию из черновика. В общем, всё как на диком западе =)
Декораторы классов в TypeScript
Давайте начнём с того, что попробуем написать простой декоратор для TypeScript класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Объявляем декоратор класса function classDecorator(target) { console.log(target, typeof target); // [class User] function } // Объявляем простой класс и применяем к нему декоратор @classDecorator class User { public firstname; public secondname; constructor(firstname, secondname) { this.firstname = firstname; this.secondname = secondname; } } |
Синтаксис применения декоратора по сравнению с JavaScript не изменился — для того чтобы указать функцию в качестве декоратора используется символ @, но вот сигнатура функции и тип передаваемого аргумента уже не такой как в JavaScript. В данном случае в декоратор класса будет передана функция-конструктор (в отличие от объекта-дескриптора в JavaScript) декорируемого класса. Доступ к конструктору класса позволяет, например, полностью переопределить его, или изменить его прототип. Давайте попробуем написать простой декоратор, который будет отмечать класс как deprecated путём переопределения его конструктора:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Объявляем декоратор, который будет помечать класс как deprecated function deprecated<T extends {new (...args: any[]): {}}>(targetConstructor: T) { return class extends targetConstructor { constructor(...args) { // Просто пишем в консоль, что инстанцируется deprecated класс console.warn(`Instantiate deprecated class: ${targetConstructor.name}`); super(...args); } } } @deprecated class User { public firstname; public secondname; constructor(firstname, secondname) { this.firstname = firstname; this.secondname = secondname; } } const user = new User('John', 'Doue'); // Instantiate deprecated class: User |
Декоратор класса описывается уже с использованием системы типов TypeScript. Указание generic типа даст понять TypeScript, что сигнатура, возвращаемого нами конструктора, будет совпадать с конструктором декорируемого класса. Внутри функции-декоратора мы делаем замену исходного конструктора на наш собственный путём объявления анонимного класса, который наследуется от исходного конструктора и непосредственно возвращается из функции-декоратора. Внутри объявления конструктора логируется warning сообщение о том, что создаётся экземпляр deprecated класса, после чего вызывается родительский конструктор в который передаются исходные аргументы. При этом сообщение о том, что используется deprecated класс мы увидим уже после того как будет создан экземпляр класса. Здесь мы подходим к ещё одной особенности того в какой последовательности декораторы будут «применяться» и когда непосредственно будет происходить «выполнение» декоратора. Давайте немного изменим deprecated декоратор, чтобы лучше понять этот механизм:
1 2 3 4 5 6 7 8 |
// Объявляем декоратор который будет помечать класс как deprecated function deprecated<T extends {new (...args: any[]): {}}>(targetConstructor: T) { console.warn(`Using deprecated class: ${targetConstructor.name}`); return class extends targetConstructor { // code... } } |
Непосредственно перед тем как сделать return внутри декоратора мы добавили вывод в консоль. И теперь после того как будет применён декоратор к классу, но до того как будет создан экземпляр этого класса, мы получим сообщение в консоли: Using deprecated class: User. Получается, что когда декоратор подключится, то будет выполнен код внутри него. В нашем случае этот код возвращает конструктор класса, который, естественно, будет вызван уже в момент создания экземпляра этого класса. Вышеописанный механизм может показаться довольно простым и очевидным. Однако, может возникнуть вопрос: в какой последовательности будут вызваны несколько декораторов, применённых к одному классу?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function classDecoratorOne<T extends {new (...args: any[]): {}}>(target: T) { console.log('Evaluated classDecoratorOne'); return class extends target { constructor(...args) { console.log(`Instantiate class: ${target.name} in classDecoratorOne`); super(...args); } } } function classDecoratorTwo<T extends {new (...args: any[]): {}}>(target: T) { console.log('Evaluated classDecoratorTwo'); return class extends target { constructor(...args) { console.log(`Instantiate class: ${target.name} in classDecoratorTwo`); super(...args); } } } // Применяем несколько декораторов @classDecoratorOne @classDecoratorTwo class User { // code... } |
Вот здесь уже всё не так очевидно. В момент привязки декоратора, если он не является фабрикой (т. е. не возвращает новую функцию декоратор), порядок применения декораторов будет идти снизу вверх, а вот порядок выполнения сверху вниз:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Применяем несколько декораторов @classDecoratorOne @classDecoratorTwo // В момент применения порядок будет следующий: // Evaluated classDecoratorTwo // Evaluated classDecoratorOne class User { // code... } // В момент создания объекта будут вызваны конструкторы, // которые были возвращены из декораторов. // Поэтому порядок вызова будет таким: // Instantiate class: User in classDecoratorOne // Instantiate class: User in classDecoratorTwo const user = new User('John', 'Doue'); |
Декораторы методов в TypeScript
В примере выше мы использовали декоратор для класса, но мы также можем применить декоратор к методу класса, при этом сигнатура у такого декоратора уже будет отличаться:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// Декоратор метода function methodDecorator(target, key, descriptor) { console.log(`Target constructor: ${target.constructor.name}`); // Target constructor: User console.log({target, key, descriptor}); // { // target: {}, // key: 'getFullName', // descriptor: { // value: [Function: getFullName], // writable: true, // enumerable: false, // configurable: true // } // } } class User { public firstname; public secondname; constructor(firstname, secondname) { this.firstname = firstname; this.secondname = secondname; } // Объявляем метод и применяем к нему декоратор @methodDecorator getFullName(): string { return this.firstname + ' ' + this.secondname; } } |
В отличие от декоратора класса декоратор метода принимает уже 3 параметра: target — прототип объекта класса к которому относится свойство (в нашем случае это функция); key — наименование этого свойства; descriptor — специальный объект, являющийся дескриптором свойства. Кратко про дескриптор свойства описано в предыдущей статье. Имея в своём распоряжении дескриптор свойства можно написать декоратор форматирующий результат вызова метода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Декоратор форматирования результата вызова метода function upperCase(target, key, descriptor) { // Переопределяем дескриптор свойства Object.defineProperty(target, key, { ...descriptor, value: function() { return descriptor.value.call(this).toUpperCase(); } }); // target будет использован как дескриптор свойства return target; } class User { // code... // Применяем декоратор @upperCase getFullName(): string { return this.firstname + ' ' + this.secondname; } } const user = new User('John', 'Doue'); console.log(user.getFullName()); // JOHN DOUE |
Как и в случае с классами декораторы методов в TypeScript отличаются сигнатурой от декораторов методов, которые мы можем использовать в JavaScript. В JavaScript декоратор будет передан только один аргумент, описывающий декорируемое свойство, здесь же мы имеем 3 аргумента. Если метод объявлен как статический, то в качестве target будет передан конструктор класса.
TypeScript позволяет использовать декораторы и для акцессоров (get/set). Т.к. под капотом они представлены методами, то и декораторы акцессоров работают также как и для методов.
Декораторы полей
С декораторами полей в TypeScript всё немного проще. Здесь передаётся только 2 аргумента: target — прототип класса или его конструктор, если поле является статическим; key — наименование свойства. Например, можно попробовать написать декоратор, который указывает обязательность поля класса быть статическим:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Декоратор проверки указания статического свойства function mustBeStatic(target, key) { if (typeof target !== 'function' || !(key in target)) { throw new Error(`Property "${key}" must by a static`); } } class User { // Указываем, что поле должно быть статическим @mustBeStatic public static role = 'USER'; // code... } |
По правде говоря, не совсем понятно зачем нужна такая проверка, но тем не менее такое тоже можно сделать =)
Декораторы параметров
В TypeScript поддерживается ещё один тип декораторов, которого пока нет в JavaScript. Правда для JavaScript есть Babel плагин, но он как раз эмулирует реализацию из TypeScript. Посмотрим, что же представляют из себя декораторы параметра:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Декоратор параметра function paramDecorator(target, key, parameterIndex) { console.log({ target, key, parameterIndex }); // { // target: {}, // key: 'someMethod', // parameterIndex: 0 // } } class User { public role = 'USER'; // code... someMethod(@paramDecorator param: any) { console.log(param); } } |
Здесь всё знакомо: target — прототип класса или его конструктор, если метод является статическим; key — наименование метода; А вот последний аргумент указывает на индекс декорируемого параметра в списке параметров (начиная с нуля). Имея на руках эту информацию о переданном аргументе, можно написать декоратор, который будет… всего лишь отслеживать объявление параметра в методе. Прямо скажем не густо, но это сказано в официальной документации TypeScript:
A parameter decorator can only be used to observe that a parameter has been declared on a method.
И вот здесь мы приходим к очередной особенности использования декораторов в TypeScript, а именно, использованию Metadata Reflection API и специальной библиотеки для работы с метаданными — reflect-metadata.
Metadata Reflection API это, также как и декораторы, нестандартизированный API EcmaScript, который позволяет добавлять различную мета-информацию в прототип и использовать её в дальнейшем непосредственно в рантайме. По сути эта библиотека является полифилом для предложенного API и, как говорит документация, после принятия стандарта декораторов будет принят и Metadata Reflection API.
Как говорилось ранее, Reflect metadata позволяет нам работать с мета-данными в рантайме, что даёт возможность делать различные дополнительные проверки. Например, можно написать декоратор, который будет указывать на обязательность указанного параметра:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// Импортируем библиотеку reflect-metadata import "reflect-metadata"; // Объявляем уникальный идентификатор для декоратора const requiredSymbol = Symbol("required"); // Объявляем сам декоратор, который повесим на параметр function required(target, propertyName, parameterIndex) { let requiredParameters: number[] = Reflect.getOwnMetadata(requiredSymbol, target, propertyName) || []; requiredParameters.push(parameterIndex); Reflect.defineMetadata( requiredSymbol, requiredParameters, target, propertyName ); }; |
Чтобы использовать возможности Reflect Metadata API для начала нужно импортировать библиотеку. После чего для указания уникального идентификатора, к которому будут привязаны метаданные, создаётся Symbol, по которому внутри декоратора мы сначала получаем существующий список параметров указанных как обязательные, а затем добавляем индекс текущего параметра в этот список и переопределяем исходные метаданные. Тут есть важный момент — из этого декоратора мы ничего не возвращаем, компилятор TS будет игнорировать для декоратора параметров возвращаемое значение. После этого объявления мы повесили метаданные об обязательных параметрах для конкретного метода прототипа, но сама проверка после этого не сработает. Для реализации проверки на уровне рантайма нужно описать ещё один декоратор, который будет относиться уже к методу с обязательными параметрами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// Декоратор метода в котором должны быть обязательные параметры function requireParams( target: any, propertyName: string, descriptor: TypedPropertyDescriptor<any> ) { // Сохраняем исходный метод const method = descriptor.value; // Переопределяем метод через дескриптор свойства descriptor.value = function () { let requiredParameters: number[] = Reflect.getOwnMetadata( requiredSymbol, target, propertyName ); if (requiredParameters) { for (const parameterIndex of requiredParameters) { if ( parameterIndex >= arguments.length || arguments[parameterIndex] === undefined ) { throw new Error(`Missing required argument [${parameterIndex}] in ${propertyName}!`); } } } return method.apply(this, arguments); }; }; |
Внутри декоратора validate мы используем переопределение метода через дескриптор свойства. В момент вызова используя уникальный идентификатор, к которому были привязаны метаданные, мы получаем список индексов обязательных параметров для этого метода. Если номер индекса параметра в массиве совпадает с количеством переданных аргументов или значение по этому индексу будет равняться undefined — выбросится ошибка с указанием индекса обязательного параметра и наименованием метода. Если же всё ok, то вызывается оригинальный метод с указанием текущего контекста и переданными аргументами. Непосредственно само применение этих декораторов может выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class User { // code... // Применяем декоратор валидации для метода // и указываем обязательность передаваемых аргументов @requireParams setFullName(@required firstname: string, @required lastname: string) { this.firstname = firstname; this.secondname = lastname; } } const user = new User("John", "Doue"); // @ts-ignore user.setFullName("Chuck"); |
Собственно применяем декораторы мы к методу setFullName, который требует указание как имени, так и фамилии. Для наглядности укажем комментарий @ts-ignore который отключит проверку типов при компиляции, но это всё равно позволит передать невалидное значение в рантайме (например undefined). Декоратор же никуда не денется и произведёт проверку уже в скомпилированном JavaScript коде. Сам скомпилированный код получается довольно интересным:
1 2 3 4 5 6 7 8 9 |
__decorate([ validate, __param(0, required), __param(1, required), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String]), __metadata("design:returntype", void 0) ], User.prototype, "setFullName", null); const user = new User("John", "Doue"); user.setFullName("Chuck"); |
Для добавления нескольких декораторов на прототип класса компилятор TypeScript создал специальную функцию __decorate, в которую передаются все наши объявленные декораторы методов и параметров. Но здесь есть ещё кое-что интересное, а именно добавление стандартных метаданных путём вызова функции __metadata. Это объявление появляется после включения в tsconfig.json опции emitDecoratorMetadata. Теперь при указании любого декоратора на прототипе или конструкторе __metadata добавит информацию о типе свойства прототипа — design:type; параметров — design:paramtypes; и возвращаемом значении — design:returntype.
И всё это теперь мы сможем получить в декораторах используя, например, метод Reflect.getOwnMetadata. Вот ещё один пример, который демонстрирует валидацию типов TypeScript на уровне JavaScript рантайма:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// Декоратор валидации типа аргумента const validate = ( target: any, propertyName: string, descriptor: TypedPropertyDescriptor<any> ) => { // Сохраняем ссылку на метод из дескриптора const method = descriptor.value; // Переопределяем метод в дескрипторе descriptor.value = function (value) { // Достаём тип переданного аргумента, // используя идентификатор "design:paramtypes" const [valueType] = Reflect.getOwnMetadata( "design:paramtypes", target, propertyName ); if (!(value instanceof valueType)) { throw new TypeError( `Invalid type, got ${typeof value} not ${valueType.name}.` ); } // Если всё ok, вызываем оригинальный метод с текущим контекстом return method.call(this, value); }; }; |
На этот раз декоратор validate будет проверять не количество обязательных аргументов, а непосредственно тип передаваемого аргумента. Вся эта проверка осуществляется через переопределение метода, используя дескриптор свойства. Вызов метода Reflect.getOwnMetadata с переданным идентификатором design:paramtypes вернёт массив типов аргументов, переданных в метод. Для упрощения мы достаём тип только первого аргумента. После этого производится сама проверка на соответствие типа экземпляра аргумента, и если тип не соответствует указанному — выбрасывается TypeError.
Применение декоратора может выглядеть следующим образом. Для демонстрации оставляем комментарий @ts-ignore:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Объявляем DTO для заполнения данных пользователя class UserDto { firstname: string; lastname: string; age: number; } class User { public firstname; public secondname; // Вешаем декоратор валидации аргумента метода @validate public static fromDto(dto: UserDto): User { return new User(dto.firstname, dto.lastname); } } // Вместо DTO используем литерал объекта const dto = {}; // @ts-ignore const user = User.fromDto(dto); // В рантайме получаем ошибку: Invalid type, got object not UserDto. |
Здесь нужно уточнить, что такая реализация декоратора будет корректно проверять только экземпляры конкретных классов. Например, мы не сможем проверить является ли экземпляр реализацией какого-либо интерфейса, т.к. в JavaScript рантайме у нас не будет доступен тип интерфейса, вместо него будет использован Object.
Настройка использования декораторов в TypeScript
Чтобы использовать декораторы в проекте давайте сначала настроим сам TypeScript:
1 2 3 4 5 |
# Инициализируем package.json с дефолтными значениями полей npm init -y # Устанавливаем TypeScript npm install typescript |
Теперь в корне проекта создадим файл tsconfig.json со следующим содержимым:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
{ "compilerOptions": { "skipLibCheck": true, "strictNullChecks": false, "module": "commonjs", "declaration": false, "noImplicitAny": false, "removeComments": true, "noLib": false, "allowSyntheticDefaultImports": true, "target": "es2020", "sourceMap": false, "allowJs": true, "outDir": "./dist", "checkJs": true, "watch": true }, "include": ["./src/*.ts"], "exclude": ["node_modules", "**/*.spec.ts"] } |
В созданном файле находятся различные настройки для работы TypeScript, в том числе и настройки компиляции. Для вашего проекта содержимое может отличаться. Чтобы можно было использовать декораторы, необходимо включить опции experimentalDecorators и emitDecoratorMetadata в разделе compilerOptions:
1 2 3 4 5 6 7 8 9 |
{ "compilerOptions": { ... "emitDecoratorMetadata": true, "experimentalDecorators": true, ... }, ... } |
Опция experimentalDecorators включает саму поддержку декораторов в языке: синтаксис и возможность применять их к класcам, методам, свойствам и параметрам. А опция emitDecoratorMetadata добавляет автоматическое указание мета-данных в прототипе через функцию __metadata (см. работу с декораторами параметров). Для полноценной поддержки Metadata Reflection API также нужно установить пакет reflect-metadata:
1 |
npm install reflect-metadata |
Заключение
Как вы могли видеть, реализация декораторов в TypeScript довольно сильно отличается от того, что есть на сегодняшний день в JavaScript. В любом случае стоит учитывать экспериментальный статус этой фичи и, что она ещё может измениться в будущем. Поэтому если вы пишите на таких фреймворках как Nest.js или Angular старайтесь в своей реализации бизнес-логики как можно меньше завязываться на сам фреймворк и такие нестандартизированные фичи языка, которые активно эксплуатируются этими фреймворками. Это позволит вам в будущем с меньшими потерями перейти на другие решения. Все примеры кода и конфигурация доступны на GitHub. Спасибо за внимание!