Українська

Дослідіть потужність декораторів TypeScript для метапрограмування, аспектно-орієнтованого програмування та покращення коду за допомогою декларативних патернів. Вичерпний посібник для розробників.

Декоратори TypeScript: Опанування патернів метапрограмування для надійних застосунків

У величезному світі сучасної розробки програмного забезпечення підтримка чистого, масштабованого та керованого коду є першочерговою. TypeScript, з його потужною системою типів та розширеними можливостями, надає розробникам інструменти для досягнення цього. Серед його найцікавіших і трансформаційних функцій є декоратори. Хоча на момент написання статті декоратори все ще є експериментальною функцією (пропозиція Stage 3 для ECMAScript), вони широко використовуються у фреймворках, таких як Angular та TypeORM, фундаментально змінюючи наш підхід до патернів проєктування, метапрограмування та аспектно-орієнтованого програмування (АОП).

Цей вичерпний посібник глибоко зануриться в декоратори TypeScript, досліджуючи їх механіку, різноманітні типи, практичні застосування та найкращі практики. Незалежно від того, чи створюєте ви великомасштабні корпоративні застосунки, мікросервіси чи клієнтські вебінтерфейси, розуміння декораторів дозволить вам писати більш декларативний, підтримуваний та потужний код на TypeScript.

Розуміння основної концепції: Що таке декоратор?

За своєю суттю, декоратор — це особливий вид оголошення, який можна приєднати до оголошення класу, методу, аксесора, властивості або параметра. Декоратори — це функції, які повертають нове значення (або змінюють існуюче) для цілі, яку вони декорують. Їх основне призначення — додавати метадані або змінювати поведінку оголошення, до якого вони прикріплені, не змінюючи безпосередньо базову структуру коду. Цей зовнішній, декларативний спосіб розширення коду є неймовірно потужним.

Думайте про декоратори як про анотації або мітки, які ви застосовуєте до частин вашого коду. Ці мітки потім можуть бути прочитані або використані іншими частинами вашого застосунку або фреймворками, часто під час виконання, для надання додаткової функціональності або конфігурації.

Синтаксис декоратора

Декоратори починаються із символу @, за яким слідує назва функції-декоратора. Вони розміщуються безпосередньо перед оголошенням, яке декорують.

@MyDecorator\nclass MyClass {\n  @AnotherDecorator\n  myMethod() {\n    // ...\n  }\n}

Увімкнення декораторів у TypeScript

Перш ніж ви зможете використовувати декоратори, ви повинні увімкнути опцію компілятора experimentalDecorators у вашому файлі tsconfig.json. Крім того, для розширених можливостей рефлексії метаданих (часто використовуваних фреймворками) вам також знадобляться emitDecoratorMetadata та поліфіл reflect-metadata.

// tsconfig.json\n{\n  "compilerOptions": {\n    "target": "ES2017",\n    "module": "commonjs",\n    "experimentalDecorators": true,\n    "emitDecoratorMetadata": true,\n    "outDir": "./dist",\n    "strict": true,\n    "esModuleInterop": true,\n    "skipLibCheck": true,\n    "forceConsistentCasingInFileNames": true\n  }\n}

Вам також потрібно встановити reflect-metadata:

npm install reflect-metadata --save\n# or\nyarn add reflect-metadata

І імпортувати його на самому початку вхідної точки вашого застосунку (наприклад, main.ts або app.ts):

import "reflect-metadata";\n// Your application code follows

Фабрики декораторів: Налаштування у ваших руках

Хоча базовий декоратор — це функція, часто вам потрібно буде передавати аргументи декоратору для налаштування його поведінки. Це досягається за допомогою фабрики декораторів. Фабрика декораторів — це функція, яка повертає власне функцію-декоратор. Коли ви застосовуєте фабрику декораторів, ви викликаєте її з аргументами, і вона повертає функцію-декоратор, яку TypeScript застосовує до вашого коду.

Приклад створення простої фабрики декораторів

Створімо фабрику для декоратора Logger, який може логувати повідомлення з різними префіксами.

function Logger(prefix: string) {\n  return function (target: Function) {\n    console.log(`[${prefix}] Class ${target.name} has been defined.`);\n  };\n}\n\n@Logger("APP_INIT")\nclass ApplicationBootstrap {\n  constructor() {\n    console.log("Application is starting...");\n  }\n}\n\nconst app = new ApplicationBootstrap();\n// Вивід:\n// [APP_INIT] Class ApplicationBootstrap has been defined.\n// Application is starting...

У цьому прикладі Logger("APP_INIT") — це виклик фабрики декораторів. Вона повертає власне функцію-декоратор, яка приймає target: Function (конструктор класу) як свій аргумент. Це дозволяє динамічно налаштовувати поведінку декоратора.

Типи декораторів у TypeScript

TypeScript підтримує п'ять різних типів декораторів, кожен з яких застосовується до певного виду оголошення. Сигнатура функції-декоратора змінюється залежно від контексту, в якому вона застосовується.

1. Декоратори класів

Декоратори класів застосовуються до оголошень класів. Функція-декоратор отримує конструктор класу як єдиний аргумент. Декоратор класу може спостерігати, змінювати або навіть замінювати визначення класу.

Сигнатура:

function ClassDecorator(target: Function) { ... }

Значення, що повертається:

Якщо декоратор класу повертає значення, воно замінить оголошення класу наданою функцією-конструктором. Це потужна функція, яка часто використовується для міксинів або розширення класів. Якщо значення не повертається, використовується оригінальний клас.

Випадки використання:

Приклад декоратора класу: Впровадження сервісу

Уявімо простий сценарій впровадження залежностей, де ви хочете позначити клас як "ін'єкційний" і, за бажанням, надати для нього ім'я в контейнері.

const InjectableServiceRegistry = new Map<string, Function>();\n\nfunction Injectable(name?: string) {\n  return function<T extends { new(...args: any[]): {} }>(constructor: T) {\n    const serviceName = name || constructor.name;\n    InjectableServiceRegistry.set(serviceName, constructor);\n    console.log(`Registered service: ${serviceName}`);\n\n    // Optionally, you could return a new class here to augment behavior\n    return class extends constructor {\n      createdAt = new Date();\n      // Additional properties or methods for all injected services\n    };\n  };\n}\n\n@Injectable("UserService")\nclass UserDataService {\n  getUsers() {\n    return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];\n  }\n}\n\n@Injectable()\nclass ProductDataService {\n  getProducts() {\n    return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];\n  }\n}\n\nconsole.log("--- Services Registered ---");\nconsole.log(Array.from(InjectableServiceRegistry.keys()));\n\nconst userServiceConstructor = InjectableServiceRegistry.get("UserService");\nif (userServiceConstructor) {\n  const userServiceInstance = new userServiceConstructor();\n  console.log("Users:", userServiceInstance.getUsers());\n  // console.log("User Service Created At:", userServiceInstance.createdAt); // If the returned class is used\n}

Цей приклад демонструє, як декоратор класу може зареєструвати клас і навіть змінити його конструктор. Декоратор Injectable робить клас доступним для теоретичної системи впровадження залежностей.

2. Декоратори методів

Декоратори методів застосовуються до оголошень методів. Вони отримують три аргументи: цільовий об'єкт (для статичних членів — функцію-конструктор; для членів екземпляра — прототип класу), назву методу та дескриптор властивості методу.

Сигнатура:

function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

Значення, що повертається:

Декоратор методу може повернути новий PropertyDescriptor. Якщо він це робить, цей дескриптор буде використаний для визначення методу. Це дозволяє вам змінювати або замінювати реалізацію оригінального методу, що робить його неймовірно потужним для АОП.

Випадки використання:

Приклад декоратора методу: Моніторинг продуктивності

Створімо декоратор MeasurePerformance для логування часу виконання методу.

function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {\n  const originalMethod = descriptor.value;\n\n  descriptor.value = function(...args: any[]) {\n    const start = process.hrtime.bigint();\n    const result = originalMethod.apply(this, args);\n    const end = process.hrtime.bigint();\n    const duration = Number(end - start) / 1_000_000;\n    console.log(`Method \"${propertyKey}\" executed in ${duration.toFixed(2)} ms`);\n    return result;\n  };\n\n  return descriptor;\n}\n\nclass DataProcessor {\n  @MeasurePerformance\n  processData(data: number[]): number[] {\n    // Simulate a complex, time-consuming operation\n    for (let i = 0; i < 1_000_000; i++) {\n      Math.sin(i);\n    }\n    return data.map(n => n * 2);\n  }\n\n  @MeasurePerformance\n  fetchRemoteData(id: string): Promise<string> {\n    return new Promise(resolve => {\n      setTimeout(() => {\n        resolve(`Data for ID: ${id}`);\n      }, 500);\n    });\n  }\n}\n\nconst processor = new DataProcessor();\nprocessor.processData([1, 2, 3]);\nprocessor.fetchRemoteData("abc").then(result => console.log(result));

Декоратор MeasurePerformance обгортає оригінальний метод логікою вимірювання часу, виводячи тривалість виконання, не захаращуючи бізнес-логіку всередині самого методу. Це класичний приклад аспектно-орієнтованого програмування (АОП).

3. Декоратори аксесорів

Декоратори аксесорів застосовуються до оголошень аксесорів (get та set). Подібно до декораторів методів, вони отримують цільовий об'єкт, назву аксесора та його дескриптор властивості.

Сигнатура:

function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

Значення, що повертається:

Декоратор аксесора може повернути новий PropertyDescriptor, який буде використаний для визначення аксесора.

Випадки використання:

Приклад декоратора аксесора: Кешування геттерів

Створімо декоратор, який кешує результат дорогого обчислення в геттері.

function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n  const originalGetter = descriptor.get;\n  const cacheKey = `_cached_${String(propertyKey)}`;\n\n  if (originalGetter) {\n    descriptor.get = function() {\n      if (this[cacheKey] === undefined) {\n        console.log(`[Cache Miss] Computing value for ${String(propertyKey)}`);\n        this[cacheKey] = originalGetter.apply(this);\n      } else {\n        console.log(`[Cache Hit] Using cached value for ${String(propertyKey)}`);\n      }\n      return this[cacheKey];\n    };\n  }\n  return descriptor;\n}\n\nclass ReportGenerator {\n  private data: number[];\n\n  constructor(data: number[]) {\n    this.data = data;\n  }\n\n  // Simulates an expensive computation\n  @CachedGetter\n  get expensiveSummary(): number {\n    console.log("Performing expensive summary calculation...");\n    return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;\n  }\n}\n\nconst generator = new ReportGenerator([10, 20, 30, 40, 50]);\n\nconsole.log("First access:", generator.expensiveSummary);\nconsole.log("Second access:", generator.expensiveSummary);\nconsole.log("Third access:", generator.expensiveSummary);

Цей декоратор гарантує, що обчислення геттера expensiveSummary виконується лише один раз, а наступні виклики повертають кешоване значення. Цей патерн дуже корисний для оптимізації продуктивності, коли доступ до властивості включає складні обчислення або зовнішні виклики.

4. Декоратори властивостей

Декоратори властивостей застосовуються до оголошень властивостей. Вони отримують два аргументи: цільовий об'єкт (для статичних членів — функцію-конструктор; для членів екземпляра — прототип класу) та назву властивості.

Сигнатура:

function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }

Значення, що повертається:

Декоратори властивостей не можуть повертати жодного значення. Їх основне призначення — реєструвати метадані про властивість. Вони не можуть безпосередньо змінювати значення властивості або її дескриптор під час декорування, оскільки дескриптор властивості ще не повністю визначений, коли запускаються декоратори властивостей.

Випадки використання:

Приклад декоратора властивості: Валідація обов'язкового поля

Створімо декоратор, щоб позначити властивість як "обов'язкову", а потім валідувати її під час виконання.

interface ValidationRule {\n  property: string | symbol;\n  validate: (value: any) => boolean;\n  message: string;\n}\n\nconst validationRules: Map<Function, ValidationRule[]> = new Map();\n\nfunction Required(target: Object, propertyKey: string | symbol) {\n  const rules = validationRules.get(target.constructor) || [];\n  rules.push({\n    property: propertyKey,\n    validate: (value: any) => value !== null && value !== undefined && value !== "",\n    message: `${String(propertyKey)} is required.`\n  });\n  validationRules.set(target.constructor, rules);\n}\n\nfunction validate(instance: any): string[] {\n  const classRules = validationRules.get(instance.constructor) || [];\n  const errors: string[] = [];\n\n  for (const rule of classRules) {\n    if (!rule.validate(instance[rule.property])) {\n      errors.push(rule.message);\n    }\n  }\n  return errors;\n}\n\nclass UserProfile {\n  @Required\n  firstName: string;\n\n  @Required\n  lastName: string;\n\n  age?: number;\n\n  constructor(firstName: string, lastName: string, age?: number) {\n    this.firstName = firstName;\n    this.lastName = lastName;\n    this.age = age;\n  }\n}\n\nconst user1 = new UserProfile("John", "Doe", 30);\nconsole.log("User 1 validation errors:", validate(user1)); // []\n\nconst user2 = new UserProfile("", "Smith");\nconsole.log("User 2 validation errors:", validate(user2)); // ["firstName is required."]\n\nconst user3 = new UserProfile("Alice", "");\nconsole.log("User 3 validation errors:", validate(user3)); // ["lastName is required."]

Декоратор Required просто реєструє правило валідації в центральній мапі validationRules. Окрема функція validate потім використовує ці метадані для перевірки екземпляра під час виконання. Цей патерн відокремлює логіку валідації від визначення даних, роблячи її чистою та придатною для повторного використання.

5. Декоратори параметрів

Декоратори параметрів застосовуються до параметрів у конструкторі класу або методі. Вони отримують три аргументи: цільовий об'єкт (для статичних членів — функцію-конструктор; для членів екземпляра — прототип класу), назву методу (або undefined для параметрів конструктора) та порядковий індекс параметра у списку параметрів функції.

Сигнатура:

function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }

Значення, що повертається:

Декоратори параметрів не можуть повертати жодного значення. Як і декоратори властивостей, їх основна роль — додавати метадані про параметр.

Випадки використання:

Приклад декоратора параметра: Впровадження даних запиту

Симулюймо, як вебфреймворк може використовувати декоратори параметрів для впровадження конкретних даних у параметр методу, наприклад, ID користувача із запиту.

interface ParameterMetadata {\n  index: number;\n  key: string | symbol;\n  resolver: (request: any) => any;\n}\n\nconst parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();\n\nfunction RequestParam(paramName: string) {\n  return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {\n    const targetKey = propertyKey || "constructor";\n    let methodResolvers = parameterResolvers.get(target.constructor);\n    if (!methodResolvers) {\n      methodResolvers = new Map();\n      parameterResolvers.set(target.constructor, methodResolvers);\n    }\n    const paramMetadata = methodResolvers.get(targetKey) || [];\n    paramMetadata.push({\n      index: parameterIndex,\n      key: targetKey,\n      resolver: (request: any) => request[paramName]\n    });\n    methodResolvers.set(targetKey, paramMetadata);\n  };\n}\n\n// A hypothetical framework function to invoke a method with resolved parameters\nfunction executeWithParams(instance: any, methodName: string, request: any) {\n  const classResolvers = parameterResolvers.get(instance.constructor);\n  if (!classResolvers) {\n    return (instance[methodName] as Function).apply(instance, []);\n  }\n  const methodParamMetadata = classResolvers.get(methodName);\n  if (!methodParamMetadata) {\n    return (instance[methodName] as Function).apply(instance, []);\n  }\n\n  const args: any[] = Array(methodParamMetadata.length);\n  for (const meta of methodParamMetadata) {\n    args[meta.index] = meta.resolver(request);\n  }\n  return (instance[methodName] as Function).apply(instance, args);\n}\n\nclass UserController {\n  getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {\n    console.log(`Fetching user with ID: ${userId}, Token: ${authToken || "N/A"}`);\n    return { id: userId, name: "Jane Doe" };\n  }\n\n  deleteUser(@RequestParam("id") userId: string) {\n    console.log(`Deleting user with ID: ${userId}`);\n    return { status: "deleted", id: userId };\n  }\n}\n\nconst userController = new UserController();\n\n// Simulate an incoming request\nconst mockRequest = {\n  id: "user123",\n  token: "abc-123",\n  someOtherProp: "xyz"\n};\n\nconsole.log("\n--- Executing getUser ---");\nexecuteWithParams(userController, "getUser", mockRequest);\n\nconsole.log("\n--- Executing deleteUser ---");\nexecuteWithParams(userController, "deleteUser", { id: "user456" });

Цей приклад демонструє, як декоратори параметрів можуть збирати інформацію про необхідні параметри методів. Фреймворк може потім використовувати ці зібрані метадані для автоматичного розв'язання та впровадження відповідних значень при виклику методу, що значно спрощує логіку контролерів або сервісів.

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

Декоратори можна застосовувати в різних комбінаціях, і розуміння порядку їх виконання є вирішальним для прогнозування поведінки та уникнення несподіваних проблем.

Кілька декораторів на одній цілі

Коли до одного оголошення (наприклад, класу, методу або властивості) застосовуються кілька декораторів, вони виконуються в певному порядку: знизу вгору, або справа наліво, для їхньої оцінки. Однак їхні результати застосовуються у зворотному порядку.

@DecoratorA\n@DecoratorB\nclass MyClass {\n  // ...\n}\n

Тут спочатку буде оцінений DecoratorB, потім DecoratorA. Якщо вони змінюють клас (наприклад, повертаючи новий конструктор), модифікація від DecoratorA обгорне або застосується поверх модифікації від DecoratorB.

Приклад: Ланцюжок декораторів методів

Розглянемо два декоратори методів: LogCall та Authorization.

function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n  const originalMethod = descriptor.value;\n  descriptor.value = function (...args: any[]) {\n    console.log(`[LOG] Calling ${String(propertyKey)} with args:`, args);\n    const result = originalMethod.apply(this, args);\n    console.log(`[LOG] Method ${String(propertyKey)} returned:`, result);\n    return result;\n  };\n  return descriptor;\n}\n\nfunction Authorization(roles: string[]) {\n  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n    const originalMethod = descriptor.value;\n    descriptor.value = function (...args: any[]) {\n      const currentUserRoles = ["admin"]; // Simulate fetching current user roles\n      const authorized = roles.some(role => currentUserRoles.includes(role));\n      if (!authorized) {\n        console.warn(`[AUTH] Access denied for ${String(propertyKey)}. Required roles: ${roles.join(", ")}`);\n        throw new Error("Unauthorized access");\n      }\n      console.log(`[AUTH] Access granted for ${String(propertyKey)}`);\n      return originalMethod.apply(this, args);\n    };\n    return descriptor;\n  };\n}\n\nclass SecureService {\n  @LogCall\n  @Authorization(["admin"])\n  deleteSensitiveData(id: string) {\n    console.log(`Deleting sensitive data for ID: ${id}`);\n    return `Data ID ${id} deleted.`;\n  }\n\n  @Authorization(["user"])\n  @LogCall // Order changed here\n  fetchPublicData(query: string) {\n    console.log(`Fetching public data with query: ${query}`);\n    return `Public data for query: ${query}`; \n  }\n}\n\nconst service = new SecureService();\n\ntry {\n  console.log("\n--- Calling deleteSensitiveData (Admin User) ---");\n  service.deleteSensitiveData("record123");\n} catch (error: any) {\n  console.error(error.message);\n}\n\ntry {\n  console.log("\n--- Calling fetchPublicData (Non-Admin User) ---");\n  // Simulate a non-admin user trying to access fetchPublicData which requires 'user' role\n  // const mockUserRoles = ["guest"]; // This will fail auth\n  // To make this dynamic, you'd need a DI system or static context for current user roles.\n  // For simplicity, we assume the Authorization decorator has access to current user context.\n  // Let's adjust Authorization decorator to always assume 'admin' for demo purposes, \n  // so the first call succeeds and second fails to show different paths.\n  \n  // Re-run with user role for fetchPublicData to succeed.\n  // Imagine currentUserRoles in Authorization becomes: ['user']\n  // For this example, let's keep it simple and show the order effect.\n  service.fetchPublicData("search term"); // This will execute Auth -> Log\n} catch (error: any) {\n  console.error(error.message);\n}\n\n/* Очікуваний вивід для deleteSensitiveData:\n[AUTH] Access granted for deleteSensitiveData\n[LOG] Calling deleteSensitiveData with args: [ 'record123' ]\nDeleting sensitive data for ID: record123\n[LOG] Method deleteSensitiveData returned: Data ID record123 deleted.\n*/\n\n/* Очікуваний вивід для fetchPublicData (якщо користувач має роль 'user'):\n[LOG] Calling fetchPublicData with args: [ 'search term' ]\n[AUTH] Access granted for fetchPublicData\nFetching public data with query: search term\n[LOG] Method fetchPublicData returned: Public data for query: search term\n*/

Зверніть увагу на порядок: для deleteSensitiveData, Authorization (нижній) виконується першим, потім LogCall (верхній) обгортає його. Внутрішня логіка Authorization виконується першою. Для fetchPublicData, LogCall (нижній) виконується першим, потім Authorization (верхній) обгортає його. Це означає, що аспект LogCall буде поза аспектом Authorization. Ця різниця є критичною для наскрізних задач, таких як логування або обробка помилок, де порядок виконання може значно вплинути на поведінку.

Порядок виконання для різних цілей

Коли клас, його члени та параметри мають декоратори, порядок виконання чітко визначений:

  1. Декоратори параметрів застосовуються першими, для кожного параметра, починаючи з останнього і до першого.
  2. Потім застосовуються декоратори методів, аксесорів або властивостей для кожного члена.
  3. Нарешті, декоратори класів застосовуються до самого класу.

У кожній категорії кілька декораторів на одній цілі застосовуються знизу вгору (або справа наліво).

Приклад: Повний порядок виконання

function log(message: string) {\n  return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {\n    if (typeof descriptorOrIndex === 'number') {\n      console.log(`Param Decorator: ${message} on parameter #${descriptorOrIndex} of ${String(propertyKey || "constructor")}`);\n    } else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {\n      if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {\n        console.log(`Method/Accessor Decorator: ${message} on ${String(propertyKey)}`);\n      } else {\n        console.log(`Property Decorator: ${message} on ${String(propertyKey)}`);\n      }\n    } else {\n      console.log(`Class Decorator: ${message} on ${target.name}`);\n    }\n    return descriptorOrIndex; // Return descriptor for method/accessor, undefined for others\n  };\n}\n\n@log("Class Level D")\n@log("Class Level C")\nclass MyDecoratedClass {\n  @log("Static Property A")\n  static staticProp: string = "";\n\n  @log("Instance Property B")\n  instanceProp: number = 0;\n\n  @log("Method D")\n  @log("Method C")\n  myMethod(\n    @log("Parameter Z") paramZ: string,\n    @log("Parameter Y") paramY: number\n  ) {\n    console.log("Method myMethod executed.");\n  }\n\n  @log("Getter/Setter F")\n  get myAccessor() {\n    return "";\n  }\n\n  set myAccessor(value: string) {\n    //...\n  }\n\n  constructor() {\n    console.log("Constructor executed.");\n  }\n}\n\nnew MyDecoratedClass();\n// Call method to trigger method decorator\nnew MyDecoratedClass().myMethod("hello", 123);\n\n/* Прогнозований порядок виводу (приблизний, залежно від версії TypeScript та компіляції):\nParam Decorator: Parameter Y on parameter #1 of myMethod\nParam Decorator: Parameter Z on parameter #0 of myMethod\nProperty Decorator: Static Property A on staticProp\nProperty Decorator: Instance Property B on instanceProp\nMethod/Accessor Decorator: Getter/Setter F on myAccessor\nMethod/Accessor Decorator: Method C on myMethod\nMethod/Accessor Decorator: Method D on myMethod\nClass Decorator: Class Level C on MyDecoratedClass\nClass Decorator: Class Level D on MyDecoratedClass\nConstructor executed.\nMethod myMethod executed.\n*/

Точний час виводу в консоль може трохи відрізнятися залежно від того, коли викликається конструктор або метод, але порядок, в якому виконуються самі функції-декоратори (і, отже, застосовуються їхні побічні ефекти або повернуті значення), відповідає наведеним вище правилам.

Практичні застосування та патерни проєктування з декораторами

Декоратори, особливо в поєднанні з поліфілом reflect-metadata, відкривають нову сферу програмування, керованого метаданими. Це дозволяє створювати потужні патерни проєктування, які абстрагують шаблонний код та наскрізні задачі.

1. Впровадження залежностей (DI)

Одним з найпомітніших застосувань декораторів є фреймворки впровадження залежностей (такі як @Injectable(), @Component() в Angular, або широке використання DI в NestJS). Декоратори дозволяють оголошувати залежності безпосередньо в конструкторах або властивостях, що дає змогу фреймворку автоматично створювати екземпляри та надавати правильні сервіси.

Приклад: Спрощене впровадження сервісу

import "reflect-metadata"; // Essential for emitDecoratorMetadata\n\nconst INJECTABLE_METADATA_KEY = Symbol("injectable");\nconst INJECT_METADATA_KEY = Symbol("inject");\n\nfunction Injectable() {\n  return function (target: Function) {\n    Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);\n  };\n}\n\nfunction Inject(token: any) {\n  return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {\n    const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];\n    existingInjections[parameterIndex] = token;\n    Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);\n  };\n}\n\nclass Container {\n  private static instances = new Map<any, any>();\n\n  static resolve<T>(target: { new (...args: any[]): T }): T {\n    if (Container.instances.has(target)) {\n      return Container.instances.get(target);\n    }\n\n    const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);\n    if (!isInjectable) {\n      throw new Error(`Class ${target.name} is not marked as @Injectable.`);\n    }\n\n    // Get constructor parameters' types (requires emitDecoratorMetadata)\n    const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];\n    const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];\n\n    const dependencies = paramTypes.map((paramType, index) => {\n      // Use explicit @Inject token if provided, otherwise infer type\n      const token = explicitInjections[index] || paramType;\n      if (token === undefined) {\n        throw new Error(`Cannot resolve parameter at index ${index} for ${target.name}. It might be a circular dependency or primitive type without explicit @Inject.`);\n      }\n      return Container.resolve(token);\n    });\n\n    const instance = new target(...dependencies);\n    Container.instances.set(target, instance);\n    return instance;\n  }\n}\n\n// Define services\n@Injectable()\nclass DatabaseService {\n  connect() {\n    console.log("Connecting to database...");\n    return "DB Connection";\n  }\n}\n\n@Injectable()\nclass AuthService {\n  private db: DatabaseService;\n\n  constructor(db: DatabaseService) {\n    this.db = db;\n  }\n\n  login() {\n    console.log(`AuthService: Authenticating using ${this.db.connect()}`);\n    return "User logged in";\n  }\n}\n\n@Injectable()\nclass UserService {\n  private authService: AuthService;\n  private dbService: DatabaseService; // Example of injecting via property using a custom decorator or framework feature\n\n  constructor(@Inject(AuthService) authService: AuthService,\n              @Inject(DatabaseService) dbService: DatabaseService) {\n    this.authService = authService;\n    this.dbService = dbService;\n  }\n\n  getUserProfile() {\n    this.authService.login();\n    this.dbService.connect();\n    console.log("UserService: Fetching user profile...");\n    return { id: 1, name: "Global User" };\n  }\n}\n\n// Resolve the main service\nconsole.log("--- Resolving UserService ---");\nconst userService = Container.resolve(UserService);\nconsole.log(userService.getUserProfile());\n\nconsole.log("\n--- Resolving AuthService (should be cached) ---");\nconst authService = Container.resolve(AuthService);\nauthService.login();

Цей складний приклад демонструє, як декоратори @Injectable та @Inject, у поєднанні з reflect-metadata, дозволяють кастомному Container автоматично розв'язувати та надавати залежності. Метадані design:paramtypes, які автоматично випромінює TypeScript (коли emitDecoratorMetadata встановлено в true), тут є вирішальними.

2. Аспектно-орієнтоване програмування (АОП)

АОП фокусується на модуляризації наскрізних задач (наприклад, логування, безпека, транзакції), які проходять через багато класів і модулів. Декоратори є чудовим інструментом для реалізації концепцій АОП у TypeScript.

Приклад: Логування за допомогою декоратора методу

Повертаючись до декоратора LogCall, це ідеальний приклад АОП. Він додає логування до будь-якого методу, не змінюючи оригінальний код методу. Це відокремлює "що робити" (бізнес-логіку) від "як це робити" (логування, моніторинг продуктивності тощо).

function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n  const originalMethod = descriptor.value;\n  descriptor.value = function (...args: any[]) {\n    console.log(`[LOG AOP] Entering method: ${String(propertyKey)} with args:`, args);\n    try {\n      const result = originalMethod.apply(this, args);\n      console.log(`[LOG AOP] Exiting method: ${String(propertyKey)} with result:`, result);\n      return result;\n    } catch (error: any) {\n      console.error(`[LOG AOP] Error in method ${String(propertyKey)}:`, error.message);\n      throw error;\n    }\n  };\n  return descriptor;\n}\n\nclass PaymentProcessor {\n  @LogMethod\n  processPayment(amount: number, currency: string) {\n    if (amount <= 0) {\n      throw new Error("Payment amount must be positive.");\n    }\n    console.log(`Processing payment of ${amount} ${currency}...`);\n    return `Payment of ${amount} ${currency} processed successfully.`;\n  }\n\n  @LogMethod\n  refundPayment(transactionId: string) {\n    console.log(`Refunding payment for transaction ID: ${transactionId}...`);\n    return `Refund initiated for ${transactionId}.`;\n  }\n}\n\nconst processor = new PaymentProcessor();\nprocessor.processPayment(100, "USD");\ntry {\n  processor.processPayment(-50, "EUR");\n} catch (error: any) {\n  console.error("Caught error:", error.message);\n}

Такий підхід дозволяє класу PaymentProcessor зосередитися виключно на логіці платежів, тоді як декоратор LogMethod обробляє наскрізну задачу логування.

3. Валідація та трансформація

Декоратори неймовірно корисні для визначення правил валідації безпосередньо на властивостях або для трансформації даних під час серіалізації/десеріалізації.

Приклад: Валідація даних за допомогою декораторів властивостей

Приклад @Required раніше вже це демонстрував. Ось ще один приклад з валідацією числового діапазону.

interface FieldValidationRule {\n  property: string | symbol;\n  validator: (value: any) => boolean;\n  message: string;\n}\n\nconst fieldValidationRules = new Map<Function, FieldValidationRule[]>();\n\nfunction addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {\n  const rules = fieldValidationRules.get(target.constructor) || [];\n  rules.push({ property: propertyKey, validator, message });\n  fieldValidationRules.set(target.constructor, rules);\n}\n\nfunction IsPositive(target: Object, propertyKey: string | symbol) {\n  addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} must be a positive number.`);\n}\n\nfunction MaxLength(maxLength: number) {\n  return function (target: Object, propertyKey: string | symbol) {\n    addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} must be at most ${maxLength} characters long.`);\n  };\n}\n\nclass Product {\n  @MaxLength(50)\n  name: string;\n\n  @IsPositive\n  price: number;\n\n  constructor(name: string, price: number) {\n    this.name = name;\n    this.price = price;\n  }\n\n  static validate(instance: any): string[] {\n    const errors: string[] = [];\n    const rules = fieldValidationRules.get(instance.constructor) || [];\n    for (const rule of rules) {\n      if (!rule.validator(instance[rule.property])) {\n        errors.push(rule.message);\n      }\n    }\n    return errors;\n  }\n}\n\nconst product1 = new Product("Laptop", 1200);\nconsole.log("Product 1 errors:", Product.validate(product1)); // []\n\nconst product2 = new Product("Very long product name that exceeds fifty characters limit for testing purpose", 50);\nconsole.log("Product 2 errors:", Product.validate(product2)); // ["name must be at most 50 characters long."]\n\nconst product3 = new Product("Book", -10);\nconsole.log("Product 3 errors:", Product.validate(product3)); // ["price must be a positive number."]

Така структура дозволяє вам декларативно визначати правила валідації на властивостях вашої моделі, роблячи ваші моделі даних самоописовими з точки зору їхніх обмежень.

Найкращі практики та міркування

Хоча декоратори є потужними, їх слід використовувати розсудливо. Неправильне використання може призвести до коду, який важче налагоджувати або розуміти.

Коли використовувати декоратори (а коли ні)

Вплив на продуктивність

Декоратори виконуються під час компіляції (або під час визначення в середовищі виконання JavaScript, якщо транспільовано). Трансформація або збір метаданих відбувається, коли клас/метод визначається, а не при кожному виклику. Тому вплив на продуктивність під час виконання від *застосування* декораторів є мінімальним. Однак, *логіка всередині* ваших декораторів може впливати на продуктивність, особливо якщо вони виконують дорогі операції при кожному виклику методу (наприклад, складні обчислення в декораторі методу).

Підтримуваність та читабельність

Декоратори, при правильному використанні, можуть значно покращити читабельність, виносячи шаблонний код з основної логіки. Однак, якщо вони виконують складні, приховані трансформації, налагодження може стати складним. Переконайтеся, що ваші декоратори добре задокументовані, а їхня поведінка передбачувана.

Експериментальний статус та майбутнє декораторів

Важливо ще раз наголосити, що декоратори TypeScript базуються на пропозиції Stage 3 TC39. Це означає, що специфікація в основному стабільна, але може зазнати незначних змін, перш ніж стати частиною офіційного стандарту ECMAScript. Фреймворки, такі як Angular, прийняли їх, роблячи ставку на їхню майбутню стандартизацію. Це означає певний рівень ризику, хоча, враховуючи їх широке поширення, значні зміни, що порушують сумісність, є малоймовірними.

Пропозиція TC39 еволюціонувала. Поточна реалізація TypeScript базується на старішій версії пропозиції. Існує різниця між "застарілими декораторами" та "стандартними декораторами". Коли офіційний стандарт буде прийнято, TypeScript, ймовірно, оновить свою реалізацію. Для більшості розробників, які використовують фреймворки, цей перехід буде керований самим фреймворком. Для авторів бібліотек може стати необхідним розуміння тонких відмінностей між застарілими та майбутніми стандартними декораторами.

Опція компілятора emitDecoratorMetadata

Ця опція, коли встановлена в true у tsconfig.json, наказує компілятору TypeScript випромінювати певні метадані типу часу проєктування в скомпільований JavaScript. Ці метадані включають тип параметрів конструктора (design:paramtypes), тип повернення методів (design:returntype) та тип властивостей (design:type).

Ці випромінені метадані не є частиною стандартного середовища виконання JavaScript. Вони зазвичай споживаються поліфілом reflect-metadata, який потім робить їх доступними через функції Reflect.getMetadata(). Це абсолютно необхідно для розширених патернів, таких як впровадження залежностей, де контейнер повинен знати типи залежностей, які вимагає клас, без явної конфігурації.

Розширені патерни з декораторами

Декоратори можна комбінувати та розширювати для створення ще більш складних патернів.

1. Декорування декораторів (декоратори вищого порядку)

Ви можете створювати декоратори, які змінюють або компонують інші декоратори. Це менш поширено, але демонструє функціональну природу декораторів.

// Декоратор, який гарантує, що метод логується, а також вимагає ролі адміністратора\nfunction AdminAndLoggedMethod() {\n  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {\n    // Застосувати Authorization першим (внутрішній)\n    Authorization(["admin"])(target, propertyKey, descriptor);\n    // Потім застосувати LogCall (зовнішній)\n    LogCall(target, propertyKey, descriptor);\n\n    return descriptor; // Повернути змінений дескриптор\n  };\n}\n\nclass AdminPanel {\n  @AdminAndLoggedMethod()\n  deleteUserAccount(userId: string) {\n    console.log(`Deleting user account: ${userId}`);\n    return `User ${userId} deleted.`;\n  }\n}\n\nconst adminPanel = new AdminPanel();\nadminPanel.deleteUserAccount("user007");\n/* Очікуваний вивід (за умови наявності ролі адміністратора):\n[AUTH] Access granted for deleteUserAccount\n[LOG] Calling deleteUserAccount with args: [ 'user007' ]\nDeleting user account: user007\n[LOG] Method deleteUserAccount returned: User user007 deleted.\n*/

Тут AdminAndLoggedMethod є фабрикою, яка повертає декоратор, і всередині цього декоратора вона застосовує два інших декоратори. Цей патерн може інкапсулювати складні композиції декораторів.

2. Використання декораторів для міксинів

Хоча TypeScript пропонує інші способи реалізації міксинів, декоратори можна використовувати для впровадження можливостей у класи декларативним способом.

function ApplyMixins(constructors: Function[]) {\n  return function (derivedConstructor: Function) {\n    constructors.forEach(baseConstructor => {\n      Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {\n        Object.defineProperty(\n          derivedConstructor.prototype,\n          name,\n          Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)\n        );\n      });\n    });\n  };\n}\n\nclass Disposable {\n  isDisposed: boolean = false;\n  dispose() {\n    this.isDisposed = true;\n    console.log("Object disposed.");\n  }\n}\n\nclass Loggable {\n  log(message: string) {\n    console.log(`[Loggable] ${message}`);\n  }\n}\n\n@ApplyMixins([Disposable, Loggable])\nclass MyResource implements Disposable, Loggable {\n  // Ці властивості/методи впроваджуються декоратором\n  isDisposed!: boolean;\n  dispose!: () => void;\n  log!: (message: string) => void;\n\n  constructor(public name: string) {\n    this.log(`Resource ${this.name} created.`);\n  }\n\n  cleanUp() {\n    this.dispose();\n    this.log(`Resource ${this.name} cleaned up.`);\n  }\n}\n\nconst resource = new MyResource("NetworkConnection");\nconsole.log(`Is disposed: ${resource.isDisposed}`);\nresource.cleanUp();\nconsole.log(`Is disposed: ${resource.isDisposed}`);

Цей декоратор @ApplyMixins динамічно копіює методи та властивості з базових конструкторів у прототип похідного класу, ефективно "домішуючи" функціональності.

Висновок: Розширення можливостей сучасної розробки на TypeScript

Декоратори TypeScript — це потужна та виразна функція, яка відкриває нову парадигму програмування, керованого метаданими та аспектно-орієнтованого програмування. Вони дозволяють розробникам розширювати, змінювати та додавати декларативну поведінку до класів, методів, властивостей, аксесорів та параметрів, не змінюючи їхньої основної логіки. Таке розділення відповідальностей призводить до чистішого, більш підтримуваного та високо reusable коду.

Від спрощення впровадження залежностей та реалізації надійних систем валідації до додавання наскрізних задач, таких як логування та моніторинг продуктивності, декоратори пропонують елегантне рішення багатьох поширених проблем розробки. Хоча їхній експериментальний статус вимагає обізнаності, їх широке поширення у великих фреймворках свідчить про їхню практичну цінність та майбутню актуальність.

Опанувавши декоратори TypeScript, ви отримуєте значний інструмент у своєму арсеналі, що дозволяє створювати більш надійні, масштабовані та інтелектуальні застосунки. Використовуйте їх відповідально, розумійте їхню механіку та відкрийте новий рівень декларативної потужності у своїх проєктах на TypeScript.

Декоратори TypeScript: Опанування патернів метапрограмування для надійних застосунків | MLOG