
Декораторы — инструмент декларативного программирования. С их помощью можно легко и элегантно добавить к классам и членам класса метаданные. На основе этих метаданных можно расширять или изменять поведения классов и членов класса, не изменяя при этом кодовую базу, к которой применен декоратор. Саму технологию можно отнести к мета-программированию или декларативному программированию.
В рамках этой статьи разбирается несколько примеров из реальных проектов, где применение декораторов сильно упростило код для понимания и исключило его дублирование.
Какую проблему решают декораторы?
С помощью декораторов мы можем избежать “дублирования” кода, инкапсулировав сквозную функциональность в отдельный модуль. Убрать лишний “шум” в коде, что позволит сфокусироваться автору на бизнес логике приложения.
Сквозная функциональность — функциональность, которая распределена по всей кодовой базе. Как правило, эта функциональность не зависит от предметной области вашего проекта. К ней можно отнести следующие примеры:
Логирование
Кеширование
Валидация
Форматирование
и т.д.
Для работы со сквозной функциональностью существует целая парадигма — Аспектно-ориентированное программирование (AOP). Про ее реализацию в JavaScript советую прочитать в этой замечательной статье. Так же существуют замечательные библиотеки, реализующие AOP в JavaScript:
Если Вам интересна тема AOP, советую поставить эти пакеты и поиграться с их функциональностью.
В этой статье я попытался показать, как можно решить описанные выше проблемы встроенной в TypeScript функциональностью — декораторы.
Для прочтения этой статьи предполагается, что вы уже имеет опыт использования react, mobx и typescript, т.к. я не буду вдаваться в подробности этих технологий.
Немного о декораторах в общем
В TypeScript декоратором является функция.
Форма применения: @funcName. Где funcName— имя функции описывающее декоратор. После прикреплении декоратора к члену класса, а затем его вызове, сначала будут выполняться декораторы, а затем уже код класса. Однако декоратор может прервать поток выполнения кода на своем уровне, так что, основной код класса в конечном счете не будет выполнен. Если к члену класса прикреплены несколько декораторов, их выполнение происходит сверху вниз по очереди.
Декораторы все еще являются экспериментальной функцией TypeScript. По этому, для их использования, вам нужно добавить в ваш tsconfig.json следующую настройку:
{ "compilerOptions": { "experimentalDecorators": true, }, }
Функция-декоратор вызывается компилятором, и компилятор сам подставляет в нее нужные аргументы.
Сигнатура этой функции для методов класса следующая:
funcName<TCls, TMethod>(target: TCls, key: string, descriptor: TypedPropertyDescriptor<TMethod>): TypedPropertyDescriptor<TMethod> | void
Где:
target — объект, для которого будет применен декоратор
key — имя метода класса, который декорируется
descriptor — дескриптор метода класса.
С помощью дескриптора, мы можем получить доступ к исходному методу объекта.
С помощью шаблонной типизации вы можете исключить неверное использование декоратора. Например, вы можете ограничить применение декоратора методами класса, у которых первый аргумент всегда имеет строковый тип, определив тип дескриптора следующим образом:
type TestDescriptor = TypedPropertyDescriptor<(id: string, ...args: any[]) => any>;
В наших примерах мы будем использовать фабрики декораторов. Фабрика декораторов — это функция которая возвращает вызываюмую декоратором во время выполнения функцию.
function format(pattern: string) { // это фабрика декораторов и она возвращает функцию-декоратора return function (target) { // это декоратор. Здесь будет код, // который что то делает с target и pattern }; }
Подготовительные работы
При рассмотрения примеров ниже мы будем использовать 2 модели данных:
export type Product = { id: number; title: string; }; export type User = { id: number; firstName: string; lastName: string; maidenName: string; }
Во всех функциях декораторах для дескриптора мы будем использовать тип PropertyDescriptor, который является эквивалентом TypedPropertyDescriptor<any>.
Добавим функцию-хелпер createDecorator, которая поможет нам сократить синтаксический сахар создания декораторов:
export type CreateDecoratorAction<T> = (self: T, originalMethod: Function, ...args: any[]) => Promise<void> | void; export function createDecorator<T = any>(action: CreateDecoratorAction<T>) { return (target: T, key: string, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value; // ссылка на оригинальный метод класса // переопределяем метод класса descriptor.value = async function (...args: any[]) { const _this = this as T; await action(_this, originalMethod, ...args); }; }; }
Проект построен на React + TypeScript. Для отображения состояние приложения на экран используется замечательная библиотека Mobx. Ниже в примерах я опущу связанные с Mobx части кода, чтобы сфокусировать ваше внимание на проблематики и ее решения.
Полную рабочую версию кода, можете найти в этом репозитории.
Отображение индикатора загрузки данных
Сперва создадим класс AppStore, который будет содержать в себе все состояние нашего маленького приложения. Приложение будет состоять из двух списков — список пользователей и список товаров. Данные будут использоваться из сервиса dummyjson.

В результате рендера страницы вызываются 2 запроса на сервер для загрузки списков. AppStore выглядит следующим образом:
class AppStore { users: User[] = []; products: Product[] = []; usersLoading = false; productsLoading = false; async loadUsers() { if (this.usersLoading) { return; } try { this.setUsersLoading(true); const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } finally { this.setUsersLoading(false); } } async loadProducts() { if (this.productsLoading) { return; } try { this.setProductsLoading(true); const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } finally { this.setProductsLoading(false); } } private setUsersLoading(value: boolean) { this.usersLoading = value; } private setProductsLoading(value: boolean) { this.usersLoading = value; } }
Изменение значения флагов usersLoading и productsLoading контролирует видимость индикаторов загрузки списка. Можно увидеть в приведенном кода выше, эта функциональность в метода повторяется. Попробуем воспользоваться декораторами, чтобы убрать это дублирование. Инкапсулируем все флаги загрузки в один объект, который будет лежать в свойстве loading нашего хранилища состояния. Для этого определим интерфейс и базовый класс (для повторного использования кода по управлению состоянием загрузки флагов):
type KeyBooleanValue = { [key: string]: boolean; }; export interface ILoadable<T> { loading: T; setLoading(key: keyof T, value: boolean): void; } export abstract class Loadable<T> implements ILoadable<T> { loading: T; constructor() { this.loading = {} as T; } setLoading(key: keyof T, value: boolean) { (this.loading as KeyBooleanValue)[key as string] = value; } }
В случае, если у вас нет возможности использовать наследование, можете использовать интерфейс ILoadable и реализовать собственный метод setLoading.
Теперь изолируем общую функциональность контроля состояния флагов в декоратор. Для этого создадим обобщенную фабрику создания декораторов loadable используя функцию хелпер createDecorator:
export const loadable = <T>(keyLoading: keyof T) => createDecorator<ILoadable<T>>(async (self, method, ...args) => { try { if (self.loading[keyLoading]) return; self.setLoading(keyLoading, true); return await method.call(self, ...args); } finally { self.setLoading(keyLoading, false); } });
Фабричная функция является обобщенной и принимает на вход ключи свойств объекта, который будет лежать в свойстве loading интерфейса ILoadable. Для корректного использования этого декоратора, нужно чтобы наш класс реализовал интерфейс ILoadable. Воспользуемся наследованием от класса Loadable, Который уже реализует этот интерфейс и перепишем наш код следующим образом:
const defaultLoading = { users: false, products: false, }; class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") async loadUsers() { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } @loadable("products") async loadProducts() { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } }
В качестве типа для объекта в свойстве loading мы передаем динамически вычисляемый тип typeof defaultLoading от состояния по умолчанию этого объекта — defaultLoading. Так же, присваиваем это состояние свойству loading. За счет этого, строковые ключи, которые мы передаем в декоратор loadable контролируется типизацией typescript. Как вы видите, методы loadUsers и loadProducts лучше читаются, а функциональность показа спиннеров инкапсулирована в отдельный модуль. Фабрика декораторов loadable и интерфейс ILoadable абстрагированы от конкретной реализации стора и могут использоваться в неограниченном количестве сторов в приложении.
Обработка ошибок в методе
Если вдруг, по какой либо причине, в приведенном выше примере сервис dummyjson перестанет быть доступным, наше приложение упадет с ошибкой и пользователь об этом не узнает. Давайте исправим эту ситуацию
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") async loadUsers() { try { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } catch (error) { notification.error({ message: "Error", description: (error as Error).message, placement: "bottomRight", }); } } @loadable("products") async loadProducts() { try { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } catch (error) { notification.error({ message: "Error", description: (error as Error).message, placement: "bottomRight", }); } } }
В каждом методе, появляется блок try … catch …, где обработка ошибок происходит в блоке catch. Всплывает уведомление в правом нижнем углу с текстом ошибки. Воспользуемся силой декораторов и инкапсулируем эту обработку в отдельный модуль, сделав ее абстрактной:
export const errorHandle = (title?: string, desc?: string) => createDecorator(async (self, method, ...args) => { try { return await method.call(self, ...args); } catch (error) { notification.error({ message: title || "Error", description: desc || (error as Error).message, placement: "bottomRight", }); } });
Фабричная функция принимает на вход необязательные параметры — кастомный заголовок и описание ошибки, которые будут выводиться в уведомлении. Если параметры не будут заполнены то будет использоваться заголовок по умолчанию и сообщение из поля message ошибки. Используем функцию errorHandle в нашем коде:
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") @errorHandle() async loadUsers() { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } @loadable("products") @errorHandle() async loadProducts() { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } }
Так просто мы добавили функциональность обработки ошибок, убрали дублирования кода и сам код методов остался простым и читаемым.
Уведомления об успешной работе метода
Допустим, что нам нужно сообщить об успешной загрузки списков пользователей и продуктов. Без декораторов код выглядел бы следующим образом:
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") @errorHandle() async loadUsers() { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; notification.success({ message: "Users uploaded successfully", placement: "bottomRight", }); } @loadable("products") @errorHandle() async loadProducts() { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; notification.success({ message: "Products uploaded successfully", placement: "bottomRight", }); } }
Так же, инкапсулируем эту функциональность в отдельный модуль и сделаем ее абстрактной:
export const successfullyNotify = (message: string, description?: string) => createDecorator(async (self, method, ...args) => { const result = await method.call(self, ...args); notification.success({ message, description, placement: "bottomRight", }); return result; });
Фабричная функция принимает на вход обязательный параметр сообщение в уведомлении и не обязательный парметр — описание сообщения. Перепишем код с использованием этой функции:
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") @errorHandle() @successfullyNotify("Users uploaded successfully") async loadUsers() { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } @loadable("products") @errorHandle() @successfullyNotify("Products uploaded successfully") async loadProducts() { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } }
Логирование метода
Если вы собираете данные вашего приложения и потом проводите анализ, для его оптимизации и отслеживания ошибок, то вам необходимо добавлять логи в код. Рассмотрим пример логирования — вывод в консоль. Добавим логи в наш сервис без использования декоратора:
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") @errorHandle() @successfullyNotify("Users uploaded successfully") async loadUsers() { try { console.log(`Before calling the method loadUsers`); const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; console.log(`The method loadUsers worked successfully.`); } catch (error) { console.log(`An exception occurred in the method loadUsers. Exception message: `, (error as Error).message); throw error; } finally { console.log(`The method loadUsers completed`); } } @loadable("products") @errorHandle() @successfullyNotify("Products uploaded successfully") async loadProducts() { try { console.log(`Before calling the method loadProducts`); const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; console.log(`The method loadProducts worked successfully.`); } catch (error) { console.log(`An exception occurred in the method loadProducts. Exception message: `, (error as Error).message); throw error; } finally { console.log(`The method loadProducts completed`); } } }
Как видите, код в методах усложнился для восприятия и понимания. И здесь еще нет кода, который включает или отключает логи в зависимости от стейджа сборки или по каким ни будь другим критериям, что сделает код еще более сложным. Создадим универсальный декоратор для логирования.
export type LogPoint = "before" | "after" | "error" | "success"; let defaultLogPoint: LogPoint[] = ["before", "after", "error", "success"]; export function setDefaultLogPoint(logPoints: LogPoint[]) { defaultLogPoint = logPoints; } export const log = (points = defaultLogPoint) => createDecorator(async (self, method, ...args) => { try { if (points.includes("before")) { console.log(`Before calling the method ${method.name} with args: `, args); } const result = await method.call(self, ...args); if (points.includes("success")) { console.log(`The method ${method.name} worked successfully. Return value: ${result}`); } return result; } catch (error) { if (points.includes("error")) { console.log( `An exception occurred in the method ${method.name}. Exception message: `, (error as Error).message ); } throw error; } finally { if (points.includes("after")) { console.log(`The method ${method.name} completed`); } } });
В этом декораторе мы определили точки для логирования, которые можно настраивать передав их типизированный массив в первый параметр фабрики декоратора. По умолчанию логируется все. Так же, функции setDefaultLogPoint можно переопределить точки логирования по умолчанию. Применим эту фабрику в нашем коде:
class AppStore extends Loadable<typeof defaultLoading> { users: User[] = []; products: Product[] = []; constructor() { super(); this.loading = defaultLoading; } @loadable("users") @errorHandle() @successfullyNotify("Users uploaded successfully") @log() async loadUsers() { const resp = await fetch("https://dummyjson.com/users"); const data = await resp.json(); const users = data.users as User[]; this.users = users; } @loadable("products") @errorHandle() @successfullyNotify("Products uploaded successfully") @log() async loadProducts() { const resp = await fetch("https://dummyjson.com/products"); const data = await resp.json(); const products = data.users as Product[]; this.products = products; } }
Такая инкапсуляция поможет гибко настраивать включение и отключение логирования в приложении. Как видите, мы добавили много функционала не изменения при этом код с бизнес логикой приложения.
Подытожим
Области применения декораторов разнообразны и потенциал их велик. С их помощью можно легко решить целый спектр прикладных задач, сделать код элегантным и простым для чтения. Благодаря декораторам, мы можем инкапсулировать повторяющийся сквозной функционал в модули, и переиспользовать в других частях проекта или других проектах. Однако в неумелых руках такой мощный инструмент может и навредить, например, изменить прямое назначение метода. Но, на мой взгляд, это не является основанием не изучать и применять эту технологию в своих проектах. Декораторы станут отличным дополнением в ваших проектах как в ОО так и в функциональной парадигме.
Репозиторий с исходным кодом можно найти тут
Ссылка на статью на английском тут