Головна / Статті / Вибір шаблонів TypeScript за мірою складності, яку вони усувають.

Вибір шаблонів TypeScript за мірою складності, яку вони усувають.

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

5991 слів

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

Розглядайте систему типів як інструмент, який може відповідати на архітектурні запитання:

  • Чи справді можлива така комбінація значень стану?
  • Чи може цей API повернути формат даних, якого не очікує решта коду?
  • Чи може ProductId потрапити до функції, яка очікує UserId?
  • Чи можна передати компоненту пропси, які суперечать один одному?
  • Якщо додати новий стан, чи будуть усі компоненти змушені ним керуватися?
  • Чи можна замінити реалізацію, не торкаючись коду, який її викликає?
  • Чи можна повторно використати абстракцію, не звертаючись до any?
  • Шаблон — це не функція, яку можна додати окремо. Це назва рішення, яке ви розпізнаєте, коли з’являється повторювана проблема.

    Карта сучасних підходів

    Класичний поділ включає шаблони створення, структури та поведінки; TypeScript додає четверту групу технік на рівні типів, побудованих на генериках, об’єднаннях та інструментах безпеки.

                         TYPESCRIPT PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
       CREATIONAL              STRUCTURAL                BEHAVIORAL
            │                       │                        │
            ├─ Factory              ├─ Adapter              ├─ Strategy
            ├─ Builder              ├─ Facade               ├─ Observer
            ├─ Singleton            ├─ Decorator            ├─ Command
            └─ Abstract Factory     ├─ Repository           └─ State
                                    └─ Composition
    
                                    +
    
                        TYPESCRIPT TYPE PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
        Generics                Unions                  Type Safety
            │                       │                        │
            ├─ Constraints          ├─ Discriminated         ├─ Type Guards
            ├─ keyof                  Unions                ├─ Branded Types
            ├─ typeof               ├─ Result Types          ├─ Exhaustiveness
            ├─ infer                └─ State Modeling        └─ satisfies
            └─ Mapped Types
    

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

    React Component
          │
          ▼
    Custom Hook
          │
          ▼
    Service
          │
          ▼
    Repository
          │
          ▼
    API Client
          │
          ▼
    Result<T, E>
          │
          ▼
    Discriminated Union
    

    Коли типи проходять через усю цю ланцюгову структуру, TypeScript перетворюється на опис того, як система може поводитися.

    Починайте з проблеми, а не з шаблону

    Поширена помилка — міркування у неправильному напрямку:

    "I know Factory Pattern.
    Where can I use Factory?"
    

    Спочатку вибір шаблону призводить до створення абстракцій, які нікому не потрібні. Краще дозволити самій проблемі визначати вибір:

    What problem do I have?
            ↓
    Where is the complexity?
            ↓
    What is changing frequently?
            ↓
    What should remain stable?
            ↓
    What abstraction reduces that complexity?
            ↓
    Is a known pattern appropriate?
    

    Розгляньмо процес оплати, який включає низку перевірок способу оплати:

    if (paymentMethod === "card") {
      // ...
    }
    
    if (paymentMethod === "paypal") {
      // ...
    }
    if (paymentMethod === "upi") {
      // ...
    }
    

    Часто спонтанною реакцією є наступне:

    Don’t immediately think:
    

    „Цьому потрібен шаблон Strategy“. Перш ніж його використовувати, запитайтеся, чи справді ці алгоритми є взаємозамінними під один контракт. Якщо так, то шаблон Strategy підходить. Якщо ж справжня проблема полягає у тому, що деякі поля мають сенс лише для певних методів, тоді кращим інструментом може бути дискримінована unія, яка не дозволяє представляти недійсні комбінації. Знати каталог просто; справжня навичка — підбирати відповідний елемент для конкретної ситуації.

    Шаблони створення

    Singleton: один спільний екземпляр

    Синглтон гарантує, що клас має рівно один екземпляр. Приватний конструктор блокує звернення ззовні через new, а статичний метод лінощами створює та зберігає екземпляр у кеші:

    class Logger {
      private static instance: Logger;
    
    private constructor() {}
      static getInstance(): Logger {
        if (!Logger.instance) {
          Logger.instance = new Logger();
        }
        return Logger.instance;
      }
      log(message: string) {
        console.log(message);
      }
    }
    

    Користувачі запитують про спільний об’єкт замість того, щоб створити його самостійно:

    const logger = Logger.getInstance();
    logger.log("Application started");
    

    Кожен користувач у кінцевому підсумку посилається на той самий об’єкт:

                      Logger
                        │
                 getInstance()
                        │
                        ▼
                 ┌───────────┐
                 │  Logger   │
                 │ Instance  │
                 └───────────┘
                   ▲       ▲
                   │       │
              Service A  Service B
    

    Хорошими кандидатами є:

    • системи логування
    • менеджери аналітики
    • зберігачі конфігурації
    • деякі менеджери з’єднань
    • інші інфраструктурні сервіси

    Проблема полягає у тому, що Singleton — це глобальний доступ під маскою: тести стає важче ізолювати, залежності зникають з опису інтерфейсів, життєвий цикл ускладнюється, а спільний змінний стан змінюється непростежуваними способами. У сучасному фронтенді експортована з ES-модуля інстанція вже є спільною, а вставка залежностей, React Context чи бібліотеки стану забезпечують таку саму гарантію із видимими зв’язками.

    Фабрика: приховує, який клас будується

    Фабрика відокремлює рішення про те, який конкретний клас інстанціювати, від користувача. Порівняйте пряме створення:

    const payment = new StripePayment();
    

    зі створенням через делегування:

    const payment = PaymentFactory.create("stripe");
    

    Обидва постачальники реалізують один інтерфейс, а фабрика відповідає літеральному об’єднанню рядків на відповідний клас. Оскільки тип параметра — "stripe" | "paypal", невідоме ім’я призводить до неможливості компіляції:

    interface PaymentProvider {
      pay(amount: number): Promise<void>;
    }
    
    class StripePayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("Stripe:", amount);
      }
    }
    class PayPalPayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("PayPal:", amount);
      }
    }
    class PaymentFactory {
      static create(
        provider: "stripe" | "paypal"
      ): PaymentProvider {
        switch (provider) {
          case "stripe":
            return new StripePayment();
          case "paypal":
            return new PayPalPayment();
        }
      }
    }
    

    Візуально фабрика — це форк, ідентифікований назвою постачальника:

                  PaymentFactory
                           │
              ┌────────────┴────────────┐
              │                         │
           "stripe"                  "paypal"
              │                         │
              ▼                         ▼
         StripePayment             PayPalPayment
    

    Фабрика є корисною тоді, коли:

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

    Для простого створення об’єктів, як у наведеному нижче прикладі, це лише додає ще один рівень для розуміння:

    new User();
    

    Абстрактна фабрика: сім’ї пов’язаних об’єктів

    Абстрактна фабрика розширює цю ідею на групи об’єктів, які мають відповідати певним критеріям. У наборі інтерфейсів для кількох платформ веб-кнопка ніколи не повинна поєднуватися з мобільним модальним вікном, тому кожна фабрика створює єдину послідовну сім’ю об’єктів:

    interface Button {
      render(): void;
    }
    
    interface Modal {
      open(): void;
    }
    
    interface UIFactory {
      createButton(): Button;
      createModal(): Modal;
    }
    
                      UIFactory
                        │
               ┌────────┴────────┐
               ▼                 ▼
         WebUIFactory       MobileUIFactory
               │                 │
          ┌────┴────┐       ┌────┴────┐
          ▼         ▼       ▼         ▼
       Button     Modal   Button     Modal
    

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

    Builder: контрольоване, крок за кроком будування

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

    class RequestBuilder {
      private config: RequestInit = {};
    
    setMethod(method: string) {
        this.config.method = method;
        return this;
      }
      setHeaders(headers: HeadersInit) {
        this.config.headers = headers;
        return this;
      }
      setBody(body: BodyInit) {
        this.config.body = body;
        return this;
      }
      build() {
        return this.config;
      }
    }
    

    Складання запиту відбувається у вигляді чітких кроків:

    const request = new RequestBuilder()
      .setMethod("POST")
      .setHeaders({
        "Content-Type": "application/json"
      })
      .setBody(JSON.stringify(data))
      .build();
    

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

    Структурні шаблони

    Adapter: стабільний контракт навколо чужого API

    Адаптер є одним із найчастіше використовуваних паттернів у коді додатків. Припустимо, ваш код очікує такий внутрішній контракт:

    interface PaymentGateway {
      pay(amount: number): Promise<void>;
    }
    

    тоді як SDK від стороннього постачальника надає метод з іншою назвою:

    class LegacyPaymentSDK {
      makePayment(value: number) {
        // third-party implementation
      }
    }
    

    Простий адаптер реалізує ваш інтерфейс та перетворює виклики:

    class PaymentAdapter implements PaymentGateway {
      constructor(
        private readonly sdk: LegacyPaymentSDK
      ) {}
    
    async pay(amount: number) {
        this.sdk.makePayment(amount);
      }
    }
    
    Application
         │
         ▼
    PaymentGateway
         ▲
         │
    PaymentAdapter
         │
         ▼
    Third-party SDK
    

    Тепер додаток залежить від одного інтерфейсу, який належить вам:

    PaymentGateway
    

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

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

    Підтримка нового постачальника означає необхідність написання ще одного адаптера.

    Фасад: один виклик для багатоетапного процесу

    Фасад створює просту точку входу перед складною підсистемою. Процес входу може впливати на кілька сервісів:

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

    Без фасаду кожен компонент, який здійснює автентифікацію користувача, сам організовує послідовність дій:

    auth.login();
    user.load();
    permission.load();
    analytics.track();
    

    Фасад обгортає цю організацію один раз:

    class AppFacade {
      async login(username: string, password: string) {
        const token = await auth.login(username, password);
    
    const user = await userService.getUser(token);
        await permissionService.load(user);
        analytics.track("login");
        return user;
      }
    }
    

    І компонент скорочується до одного виклику:

    await appFacade.login(username, password);
    
                   Component
                         │
                         ▼
                     AppFacade
                         │
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
         Auth          User        Permission
        Service       Service       Service
    

    Це стає корисним, коли має значення порядок кроків.

    Декоратор: додавання поведінки шляхом обгортання

    Декоратор додає поведінку, не змінюючи початкову реалізацію, оскільки обгортка та обгорнутий об’єкт мають спільний інтерфейс. Контракт:

    interface Logger {
      log(message: string): void;
    }
    

    Проста реалізація:

    class ConsoleLogger implements Logger {
      log(message: string) {
        console.log(message);
      }
    }
    

    Декоратор, який зберігає будь-який Logger та додає до повідомлень попередник у вигляді часу створення:

    class TimestampLogger implements Logger {
      constructor(
        private readonly logger: Logger
      ) {}
    
    log(message: string) {
        this.logger.log(
          `[${new Date().toISOString()}] ${message}`
        );
      }
    }
    

    Обгортання — це просто процес створення об’єкта:

    const logger = new TimestampLogger(
      new ConsoleLogger()
    );
    

    Оскільки кожен декоратор сам є Logger, вони накопичуються один на інший:

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    Та сама ідея проявляється під іншими назвами:

    • ланцюги мідлверу
    • функції-обгортки
    • компоненти вищого порядку у React
    • логування
    • шари кешування
    • перевірки авторизації
  • інструменталізація та відстеження
  • Патерни поведінки

    Стратегія: взаємозамінні алгоритми

    Стратегія усуває розгалуження бізнес-правил. Візьмемо тип рівня клієнта:

    type CustomerType =
      | "regular"
      | "premium"
      | "enterprise";
    

    Умовна реалізація поміщає кожне правило в одну функцію:

    function calculateDiscount(
      type: CustomerType,
      price: number
    ) {
      if (type === "regular") {
        return price;
      }
    
    if (type === "premium") {
        return price * 0.9;
      }
      return price * 0.8;
    }
    

    За допомогою стратегії кожне правило стає класом за спільним інтерфейсом:

    interface DiscountStrategy {
      calculate(price: number): number;
    }
    
    class RegularDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price;
      }
    }
    class PremiumDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.9;
      }
    }
    class EnterpriseDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.8;
      }
    }
    
                      Order
                          │
                          ▼
                  DiscountStrategy
                          │
              ┌───────────┼───────────┐
              ▼           ▼           ▼
           Regular      Premium    Enterprise
           Strategy     Strategy    Strategy
    

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

    Обсервер: сповіщення від одного до багатьох

    Обсервер дозволяє одному об’єкту сповіщати будь-яку кількість слухачів про зміни:

                     Subject
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
          Observer A Observer B Observer C
    

    Невеликий типований емітер зберігає слухачів у Set та повертає функцію для очищення після реєстрації слухача:

    type Listener<T> = (value: T) => void;
    
    class EventEmitter<T> {
      private listeners = new Set<Listener<T>>();
      subscribe(listener: Listener<T>) {
        this.listeners.add(listener);
        return () => {
          this.listeners.delete(listener);
        };
      }
      emit(value: T) {
        this.listeners.forEach(listener => {
          listener(value);
        });
      }
    }
    
    const emitter = new EventEmitter<string>();
    
    const unsubscribe = emitter.subscribe(message => {
      console.log(message);
    });
    emitter.emit("Hello");
    unsubscribe();
    

    Ця функція для очищення виконує керування життєвим циклом. Якщо забути її викликати, це призводить до:

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

    У React саме це і повертається з колбека useEffect.

    Команда: дії як об’єкти

    Команда перетворює дію на об’єкт із уніфікованим інтерфейсом:

    interface Command {
      execute(): void;
    }
    

    Конкретні команди реалізують цей механізм:

    class SaveCommand implements Command {
      execute() {
        console.log("Saving...");
      }
    }
    
    class UndoCommand implements Command {
      execute() {
        console.log("Undo");
      }
    }
    

    Розгляд дій як значень допомагає, коли потрібно:

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

    User Action
        │
        ▼
     Command
        │
        ├── execute()
        │
        ▼
     Receiver
    

    Шаблони даних та композиції

    Repository: ізоляція доступу до даних

    Коли доступ до даних стає складним, Repository встановлює контракт між бізнес-логікою та тим, що зберігає або отримує дані:

    UI
     │
     ▼
    Hook / Controller
     │
     ▼
    Service
     │
     ▼
    Repository
     │
     ├── REST
     ├── GraphQL
     ├── IndexedDB
     └── Cache
    

    Контракт описує, про що може просити додаток:

    interface UserRepository {
      getUser(id: string): Promise<User>;
      getUsers(): Promise<User[]>;
    }
    

    Одна з реалізацій взаємодіє з REST API:

    class ApiUserRepository implements UserRepository {
      async getUser(id: string) {
        const response = await fetch(`/users/${id}`);
    
    return response.json();
      }
      async getUsers() {
        const response = await fetch("/users");
        return response.json();
      }
    }
    

    Бізнес-код залежить від інтерфейсу:

    UserRepository
    

    а не від способу передачі даних:

    fetch()
    axios()
    graphqlClient()
    

    Це має значення, коли змінюється вихідний код: перехід від REST до GraphQL, додавання кешу IndexedDB чи використання тимчасових даних у пам’яті для тестування. Зауважте, що response.json() повертає значення без вказання типу, тому репозиторій також є правильним місцем для перевірки відповідей.

    Композиція замість успадкування

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

    MegaComponent
     ├── Authentication
     ├── Table
     ├── Filters
     ├── Modal
     ├── Notifications
     ├── API calls
     └── Business logic
    

    Розділіть обов’язки на окремі частини:

    Dashboard
     ├── Header
     ├── Sidebar
     ├── FilterPanel
     ├── DataTable
     └── NotificationPanel
    

    У React це полягає у простому вкладенні компонентів:

    <Dashboard>
      <Header />
      <Sidebar />
      <MainContent />
    </Dashboard>
    

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

    Моделювання стану за допомогою системи типів

    Відтепер сам TypeScript є архітектурним інструментом.

    Дискриміновані союзи для стану запиту

    Запит проходить через фази, які містять різні дані. Дискримінований союз надає кожній фазі власну структуру, яка об’єднується за допомогою поля status:

    type RequestState =
      | {
          status: "idle";
        }
      | {
          status: "loading";
        }
      | {
          status: "success";
          data: User[];
        }
      | {
          status: "error";
          error: string;
        };
    

    Використання дискримінанта звужує тип у кожній гілці:

    function render(state: RequestState) {
      switch (state.status) {
        case "idle":
          return "Nothing started";
    
        case "loading":
          return "Loading...";
        case "success":
          return state.data;
        case "error":
          return state.error;
      }
    }
    

    Компілятор відстежує, які поля існують після кожної перевірки:

    status = "success"
            ↓
    data exists
    
    status = "error"
            ↓
    error exists
    

    Порівняйте звичайну модель boolean-і-optional:

    interface State {
      loading: boolean;
      data?: User[];
      error?: string;
    }
    

    Ніщо не заважає описувати стан, який ніколи не має виникати:

    {
      loading: true,
      data: [...],
      error: "Something failed"
    }
    

    Союз ускладнює опис таких комбінацій. Краще моделювати допустимі стани, замість того щоб розкидати опційні властивості та покладатися на те, що всі правильно їх поєднають.

    Типи результатів для явної невдачі

    Багато операцій мають рівно два результати:

    Success
       OR
    Failure
    

    Тип Result описує їх обидва за допомогою булевого показника:

    type Result<T, E> =
      | {
          success: true;
          data: T;
        }
      | {
          success: false;
          error: E;
        };
    

    Функція, яка його повертає:

    function getUser(): Result<User, string> {
      return {
        success: true,
        data: user
      };
    }
    

    Викликаючий має перевірити значення success перед тим, як працювати з data або error:

    const result = getUser();
    
    if (result.success) {
      console.log(result.data);
    } else {
      console.error(result.error);
    }
    
                 Service
                    │
                    ▼
              Result<T, E>
               /       \
              /         \
             ▼           ▼
          Success      Failure
            │             │
           data          error
    

    Це підходить для очікуваних бізнес-помилок, таких як „Електронна пошта вже зареєстрована“. Винятки все одно підходять для справжніх багів та проблем інфраструктури; тип Result просто робить очікувані помилки видимими у сигнатурі.

    Генерики та інструменти на рівні типів

    Генерики зберігають зв’язок між вхідними та вихідними даними

    Використання any втрачає інформацію:

    function identity(value: any): any {
      return value;
    }
    

    Параметр типу зберігає її:

    function identity<T>(value: T): T {
      return value;
    }
    

    Виведений результат відповідає аргументу:

    const a = identity("hello");
    // string
    
    const b = identity(100);
    // number
    

    Зв’язок між вхідними та вихідними даними зберігається:

    Input T
      │
      ▼
    Function<T>
      │
      ▼
    Output T
    

    Генерики також дозволяють повторно використовувати спільні умови. Одна оболонка відповіді:

    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
    

    описує багато навантажень даних:

    type UserResponse =
      ApiResponse<User>;
    
    type ProductResponse =
      ApiResponse<Product>;
    

    Обмеження: вимога до форми

    Це не дозволяє скомпілювати код:

    function getId<T>(item: T) {
      return item.id;
    }
    

    тому що ніщо не повідомляє TypeScript про те, що T має поля id. Обмеження додає цю гарантію:

    function getId<T extends { id: string }>(
      item: T
    ) {
      return item.id;
    }
    

    Будь-який об’єкт із рядковим значенням id приймається, включаючи додаткові поля:

    getId({
      id: "123",
      name: "Hareesh"
    });
    

    Читайте обмеження ось так:

    T can be anything
    BUT
    T must have id: string
    

    keyof та індексований доступ

    Якщо є інтерфейс:

    interface User {
      id: string;
      name: string;
      age: number;
    }
    

    keyof генерує об’єднання назв його властивостей:

    type UserKey = keyof User;
    
    "id" | "name" | "age"
    

    Поєднання keyof з другим параметром типу та індексованим доступом T[K] дає аксесор, тип повернення якого збігається з ключем:

    function getProperty<T, K extends keyof T>(
      object: T,
      key: K
    ): T[K] {
      return object[key];
    }
    
    const user = {
      id: "1",
      name: "Hareesh",
      age: 30
    };
    
    getProperty(user, "name");
    

    Справжній ключ компілюється:

    getProperty(user, "name");
    

    Відсутній ключ є помилкою компіляції:

    getProperty(user, "salary");
    

    Ефективність досягається завдяки спільній роботі трьох інструментів:

    Generics
       +
    keyof
       +
    Indexed Access
    

    Типи-мапи: трансформація існуючого типу

    Типи-мапи ітеруються по ключах типу для створення нового типу. Починаючи з:

    interface User {
      id: string;
      name: string;
      email: string;
    }
    

    можна отримати версію з усіма опціональними елементами:

    type OptionalUser = {
      [K in keyof User]?: User[K];
    };
    

    концептуально еквівалентне написанню:

    {
      id?: string;
      name?: string;
      email?: string;
    }
    

    Саме так визначаються вбудовані елементи, такі як Partial.

    Умовні типи: рішення на рівні типу

    Умовні типи обирають між двома типами на основі можливості присвоєння:

    T extends U ? X : Y
    

    Цей інструмент розпаковує типи елементів масиву та залишає все інше недоторканим:

    type Flatten<T> =
      T extends Array<infer U>
        ? U
        : T;
    
    type A = Flatten<string[]>;
    // string
    
    type B = Flatten<number>;
    // number
    

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

    infer: вилучення частини типу

    Умісті умовного типу функція infer оголошує змінну типу, яку TypeScript заповнює шляхом зіставлення. Це повторно реалізує вбудований ReturnType:

    type MyReturnType<T> =
      T extends (...args: any[]) => infer R
        ? R
        : never;
    

    Поєднайте його з typeof, щоб отримати тип із існуючої функції, щоб він ніколи не відхилявся від її реалізації:

    function getUser() {
      return {
        id: "1",
        name: "Hareesh"
      };
    }
    
    type User = MyReturnType<typeof getUser>;
    

    Визначення типів у бібліотеках сильно залежать від цього.

    Спочатку вбудовані утилітні типи

    Перш ніж писати складні допоміжні функції, дізнайтеся, що вже є у мові:

    Partial
    Required
    Readonly
    Pick
    Omit
    Record
    Exclude
    Extract
    NonNullable
    ReturnType
    Parameters
    InstanceType
    Awaited
    

    Видалення конфіденційного поля — це один рядок замість дубльованого інтерфейсу, який призведе до втрати синхронізації:

    interface User {
      id: string;
      name: string;
      email: string;
      password: string;
    }
    
    type PublicUser =
      Omit<User, "password">;
    

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

    Record із закритим набором ключів

    Record підходить для пошуку та налаштувань, особливо коли ключі походять з літерального об’єднання:

    type Permission =
      "read" |
      "write" |
      "delete";
    
    type PermissionMap =
      Record<Permission, boolean>;
    
    const permissions: PermissionMap = {
      read: true,
      write: false,
      delete: false
    };
    

    Якщо пропустити дозвіл, TypeScript повідомить про відсутній ключ. Широкий тип ключа позбавлений такої гарантії:

    const permissions: Record<string, boolean>
    

    Оскільки Record<string, boolean> приймає практично будь-який рядковий ключ, пропуски та опечатки залишаються непоміченими.

    Шаблони безпеки для ідентифікаторів та ненадійних даних

    Типи з маркуванням для ідентифікаторів домену

    Два ідентифікатори можуть бути рядками, проте означати різні речі:

    const userId: string;
    const productId: string;
    

    З точки зору структури TypeScript не може їх розрізнити. Перетинання типу string із фіктивною властивістю створює різні типи:

    type UserId =
      string & {
        readonly __brand: "UserId";
      };
    
    type ProductId =
      string & {
        readonly __brand: "ProductId";
      };
    

    Функції потім можуть вимагати певного типу ідентифікатора:

    function getUser(id: UserId) {}
    
    function getProduct(id: ProductId) {}
    

    Передача ProductId там, де очікується UserId, призводить до помилки компіляції. Бренд ніколи не існує під час виконання; значення бренду створюються за допомогою невеликого конструктора чи функції перевірки, яка здійснює приведення типу в одному місці. Концептуально:

    string
      │
      ├── UserId
      ├── ProductId
      ├── OrderId
      └── TransactionId
    

    Це має сенс у великих системах, де десятки ідентифікаторів використовують один примітивний тип.

    Типові захисники для вхідних даних типу unknown

    Дані з мережі, сховища чи введення користувача повинні надходити у форматі unknown:

    const data: unknown = await response.json();
    

    Приведення типу є спокусливим скороченим шляхом:

    const user = data as User;
    

    Замість цього використайте типовий захисник, визначений користувачем; його тип повернення value is User повідомляє компілятору, що підтверджує результат true:

    function isUser(
      value: unknown
    ): value is User {
      return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        "name" in value
      );
    }
    
    if (isUser(data)) {
      console.log(data.name);
    }
    

    Пам’ятайте, що типи стираються під час виконання. Інтерфейс на кшталт цього:

    interface User {
      id: string;
    }
    

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

    Повна перевірка за допомогою never

    Одна з найефективніших комбінацій у мові:

    Discriminated Union
            +
    never
            +
    switch
    

    Використайте союз статусів:

    type Status =
      | "loading"
      | "success"
      | "error";
    

    та допоміжну функцію, яка приймає лише never:

    function assertNever(
      value: never
    ): never {
      throw new Error(
        `Unexpected value: ${value}`
      );
    }
    

    Коли оброблено всі елементи, шлях за замовчуванням бачить тип never, тому виклик компілюється:

    function render(status: Status) {
      switch (status) {
        case "loading":
          return "Loading";
        case "success":
          return "Success";
        case "error":
          return "Error";
        default:
          return assertNever(status);
      }
    }
    

    Тепер колега розширює об’єднання:

    Now imagine someone adds:"cancelled" to Status.
    

    Стандартна гілка отримує значення "cancelled", яке не можна присвоїти значенню never, і процес будування зазнає невдачі доки цей випадок не буде оброблений. Компілятор стає перевірником дизайну, який пам’ятає кожного користувача.

    Типи літералів шаблонів

    TypeScript може створювати типи літералів рядків з інших типів:

    type Entity =
      "user" |
      "order" |
      "product";
    
    type Event =
      `${Entity}:created` |
      `${Entity}:updated` |
      `${Entity}:deleted`;
    

    Результатом є об’єднання, яке містить усі комбінації:

    user:created
    user:updated
    user:deleted
    
    order:created
    order:updated
    order:deleted
    
    product:created
    product:updated
    product:deleted
    

    Практичні застосування включають:

    • назви подій
    • ключі аналітичних подій
    • рядки дозволів
    • шаблони маршрутів
    • назви функціональних прапорців
    • токени системи дизайну

    as const коли значення є джерелом істини

    Літерал масиву рядків розширюється:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ];
    

    Його тип — string[]. Додавання as const створює лише читабельний набір літералів:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ] as const;
    

    З цього безпосередньо випливає тип об’єднання:

    type Role =
      typeof roles[number];
    
    becomes:
    
    
    "admin" |
    "editor" |
    "viewer"
    

    Визначте значення один раз та ніколи не підтримуйте паралельне об’єднання вручну.

    satisfies для перевірки конфігурації

    satisfies перевіряє вираз щодо типу, не розширюючи його до цього типу:

    type Config = {
      retries: number;
      environment:
        | "development"
        | "production";
    };
    
    const config = {
      retries: 3,
      environment: "production"
    } satisfies Config;
    

    Об’єкт перевіряється щодо Config, проте config.environment зберігає літеральний тип "production", який звичайна анотація б втратила. Це підходить для:

    • конфігурації маршрутів
    • флагів функцій
    • токенів дизайну
    • об’єктів статичних налаштувань
    • карт дозволів

    Вставка залежностей

    Створення залежностей всередині класу приєднує його до них:

    class UserService {
      private api = new ApiClient();
    }
    

    Отримання їх через конструктор зберігає клас агностичним:

    class UserService {
      constructor(
        private readonly api: ApiClient
      ) {}
    }
    

    У продакшені передається справжній клієнт:

    const service =
      new UserService(apiClient);
    

    а в тестах — мок:

    const service =
      new UserService(mockApiClient);
    
                    UserService
                          ▲
                          │
                     Dependency
                          │
                 ┌────────┴────────┐
                 ▼                 ▼
            ApiClient         MockApiClient
           Production            Testing
    

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

    Розрізнення схожих патернів

    Паттерн стану чи дискримінований союз?

    Якщо розглядати життєвий цикл замовлення:

    Draft
    Paid
    Shipped
    Cancelled
    

    можливо, дискримінований союз буде достатнім:

    type Order =
      | { status: "draft" }
      | { status: "paid" }
      | { status: "shipped" }
      | { status: "cancelled" };
    

    Але коли кожен стан має значний обсяг поведінки:

    Draft
     ├── edit()
     ├── submit()
    
    Paid
     ├── refund()
     ├── ship()
    Shipped
     ├── track()
     └── deliver()
    

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

    Стратегія чи стан?

    Часта тема інтерв’ю. За допомогою стратегії клієнт обирає алгоритм:

    Order
      │
      ▼
    Strategy
      ├── CreditCard
      ├── PayPal
      └── UPI
    

    За допомогою стану об’єкт змінює свою поведінку під час проходження свого життєвого циклу:

    Order
      │
      ├── Draft
      ├── Paid
      └── Shipped
    

    Стратегія змінює спосіб виконання завдання; стан змінює те, що робить об’єкт на своїй поточній стадії.

    Фабрика чи стратегія?

    Фабрика відповідає на запитання „який об’єкт слід створити?“:

    PaymentFactory.create("stripe");
    

    Стратегія відповідає на запитання „яка поведінка має виконуватися?“:

    new Order(discountStrategy);
    

    Вони природно поєднуються: фабрика створює стратегію, яка виконує роботу:

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    Застосування цих шаблонів у React

    Атрибути variant замість булевих флагів

    Незалежні булеві значення дозволяють наявність ситуацій, коли кнопка є водночас основною та небезпечною:

    interface ButtonProps {
      primary?: boolean;
      danger?: boolean;
      loading?: boolean;
    }
    

    Об’єднання варіантів дозволяє кожному з них визначати власні вимоги:

    type ButtonProps =
      | {
          variant: "primary";
          loading?: boolean;
        }
      | {
          variant: "danger";
          confirmationRequired: boolean;
        };
    

    Тепер варіант небезпеки має вказувати confirmationRequired.

    API компонентів, які відхиляють суперечності

    Компонент, який відображає або посилання, або кнопку з необов’язковими параметрами href та onClick, міг би дозволити наявність обох або жодного з них. Наведений нижче фрагмент демонструє нестрогу версію, варіант із розрізненням та правильне використання:

    {
      href?: string;
      onClick?: () => void;
    }
    
    use:
    type ActionProps =
      | {
          type: "link";
          href: string;
        }
      | {
          type: "button";
          onClick: () => void;
        };
    
    
    Now:
    
    <Action
      type="link"
      href="/users"
    />
    

    Варіант посилання, у якого вказаний обробник кліку замість href, відхиляється:

    <Action
      type="link"
      onClick={...}
    />
    

    Підтримувані режими, сформульовані просто:

    Link
    Button
    

    Тепер TypeScript є частиною архітектури компонентів, а не старіючою документацією.

    Безпечний за типами бус подій

    Почніть з карти, яка пов’язує назви подій із типами даних:

    type Events = {
      "user:created": User;
      "user:deleted": UserId;
      "order:created": Order;
    };
    

    Генеричний тип буса, заснований на цій карті, пов’язує кожну назву з відповідними даними через keyof та T[K]:

    class EventBus<T extends Record<string, unknown>> {
      on<K extends keyof T>(
        event: K,
        handler: (data: T[K]) => void
      ) {
        // implementation
      }
    
    emit<K extends keyof T>(
        event: K,
        data: T[K]
      ) {
        // implementation
      }
    }
    

    Правильний пейлоад компілюється:

    bus.emit("user:created", user);
    

    Неправильний — ні:

    bus.emit("user:created", order);
    

    Тут зустрічається кілька інструментів:

    Generics
    +
    keyof
    +
    Indexed Access
    +
    Mapped Type thinking
    

    Типи в усьому шарі API

    Зрілий фронтенд часто організовує доступ до даних ось так:

    Component
         ↓
    Hook
         ↓
    Service
         ↓
    Repository
         ↓
    API Client
         ↓
    HTTP
    

    з типами, які передаються на кожному етапі. Репозиторій може приймати ідентифікатори та повертати Result:

    type ApiResponse<T> = {
      data: T;
      status: number;
    };
    
    interface UserRepository {
      getUser(id: UserId):
        Promise<Result<User, ApiError>>;
    }
    

    Компонент більше не обробляє це на кожному шарі:

    any
    

    Самі сигнатури вказують, що може вдатися, що може зазнати невдачі та з якими типами.

    Антипатерни, яких слід уникати

    any скрізь

    function process(data: any) {}
    

    Параметр any вимикає перевірку всього, з чим він має справу. Краще використовувати:

    function process(data: unknown) {}
    

    та звужувати типи перед використанням.

    Асерції як валідація

    const user =
      response.data as User;
    

    Перетворення за допомогою as нічого не перевіряє; воно лише просить компілятор довіритися вам. Перевіряйте недостовірні дані під час виконання.

    Генерики заради них самих

    function transform<
      T,
      U,
      V,
      R
    >(...) {}
    

    Якщо кожен параметр типу не відображає реальних зв’язків. Параметр типу, який використовується лише один раз, зазвичай є зайвим.

    Союз із сотнею елементів важко керувати та розвивати. Можливо, потрібна інша абстракція, наприклад вкладені союзи або переміщення варіацій у дані.

    Хитромудрість на рівні типу

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

    Type complexity
           │
           ▼
    Developer complexity
           │
           ▼
    Maintenance cost
    

    Безпека типу має свою ціну. Прагніть до найкориснішої безпеки за одиницю складності, а не до найскладніших типів.

    Дерево рішень для вибору

    Це дерево відображає поширені ситуації тиску та відповідні їм шаблони:

    PROBLEM
       │
       ├── Need to create objects?
       │       ├── Simple creation → Constructor
       │       ├── Complex creation → Builder
       │       ├── Multiple implementations → Factory
       │       └── Families of objects → Abstract Factory
       │
       ├── Need to integrate another system?
       │       └── Adapter
       │
       ├── Complex subsystem?
       │       └── Facade
       │
       ├── Add behavior without modifying object?
       │       └── Decorator
       │
       ├── Multiple interchangeable algorithms?
       │       └── Strategy
       │
       ├── Subscribers react to changes?
       │       └── Observer
       │
       ├── Need actions/history/undo?
       │       └── Command
       │
       ├── Data-access abstraction?
       │       └── Repository
       │
       ├── Complex state lifecycle?
       │       └── State / Discriminated Union
       │
       └── Type-level problem?
               ├── Reuse → Generics
               ├── Transform → Mapped Types
               ├── Decision → Conditional Types
               ├── Extract → infer
               ├── Property safety → keyof
               ├── Literal safety → as const
               ├── Contract validation → satisfies
               └── Domain safety → Branded Types
    

    Вважайте його відправною точкою для обговорень; принцип „починати з простого“ залишається актуальним у кожному випадку.

    Що вивчати спочатку

    Для інженерів фронтенду, які готуються до посад старшого рівня чи керівника, запам’ятовування кожного шаблону з підходу Gang of Four — це неефективне використання часу. Практичний порядок:

    Основні елементи:

    Discriminated Unions
    Generics
    Type Guards
    keyof
    Mapped Types
    Utility Types
    Composition
    Strategy
    Repository
    Factory
    

    Надзвичайно рекомендується далі:

    Result Type
    Branded Types
    Conditional Types
    infer
    Exhaustive Checking
    Dependency Injection
    Adapter
    Facade
    Observer
    Decorator
    

    Варто зрозуміти концептуально:

    Builder
    Command
    State
    Abstract Factory
    Singleton
    

    Повсякденна робота з фронтендом значно більше ґрунтується саме на цій комбінації:

    Generics
    +
    Discriminated Unions
    +
    Composition
    +
    Strategy
    +
    Repository
    

    ніж на будь-якому теоретичному підході типу Абстрактна фабрика.

    Як змінюється судження з досвідом

    На початковому етапі інженери запитують: «Який шаблон мені слід використати тут?» З досвідом роботи з великими системами це питання перетворюється на: «Яка найменша абстракція допоможе вирішити проблему, не ускладнюючи розуміння системи?» Робочий процес, спрямований на шаблони, виглядає так:

    Problem
      ↓
    Pattern
      ↓
    More classes
      ↓
    More abstractions
    

    Робочий процес, спрямований на проблему, виглядає так:

    Problem
      ↓
    Understand volatility
      ↓
    Identify boundary
      ↓
    Start simple
      ↓
    Introduce abstraction only where repetition/change justifies it
    

    Хороші шаблони з’являються тоді, коли стають очевидними вимоги архітектури; їх не нав’язують заздалегідь.

    Запитання на співбесіді, які перевіряють справжнє розуміння

    «Що таке шаблон Factory?» — це запитання, яке перевіряє пам’ять. Інші запитання перевіряють здатність до судження.

    Архітектура:

    1. Якщо у вас є компонент React з 2 000 рядків коду, як ви вирішите, яку абстракцію ввести?
    2. Коли б ви відмовилися від шаблону, який здається підходящим?
    3. Як можна визначити, що абстракція є передчасною?
  • Коли краще використовувати композицію замість успадкування?
  • Як запобігти тому, щоб Singleton став глобальним змінним станом?
  • Основи TypeScript:

    1. Як би ви моделювали запит із станами завантаження, успіху, помилки та повторної спроби?
    2. Як запобігти недійсним комбінаціям пропсів у React?
    3. У чому різниця між unknown, any та never?
    4. Коли краще використовувати type замість interface, і навпаки?
    5. Як keyof взаємодіє з генериками?

    Розширені можливості TypeScript:

    1. Який конкретний випадок використання для умовних типів?
    2. Що робить infer?
    3. Що таке відображений тип?
    4. Що таке дистрибутивні умовні типи?
    5. Коли варто використовувати типи з мітками?
    6. Яку проблему вирішує satisfies?
    7. Як as const змінює процес висновку типу?

    Практичний дизайн:

    1. Спроєктувати безпечний за типами бус подій.
    2. Спроєктувати клієнта API, безпечного за типами.
    3. Спроєктувати систему дозволів з використанням TypeScript.
    4. Спроєктувати абстракцію оплати, яка підтримує Stripe, PayPal та ще одного постачальника.
    5. Як би ви додали шар Repository до існуючого React-додатку без повної переробки?
    6. Як би ви задавали типи подій WebSocket з різними навантаженнями даних?
    7. Як би ви запобігли передачі ProductId там, де потрібен UserId?

    Один з провокаційних запитань: «Опишіть момент, коли ви навмисно вирішили не використовувати шаблон проектування». Хороша відповідь пояснює компроміс: при наявності єдиної реалізації та відсутності ймовірних змін, які потрібно було б урахувати, непрямий підхід не зменшував би складність, тож код залишався простим доти, доки інший випадок використання не зробив це доцільним.

    Остання модель мислення

    Почніть з бізнес-проблеми, визначте джерело складності, розділіть зміни поведінки від структурних змін, а потім дозвольте безпеці типів покращити результат.

                     BUSINESS PROBLEM
                            │
                            ▼
                    Identify Complexity
                            │
                            ▼
                 What is likely to change?
                            │
                 ┌──────────┴──────────┐
                 ▼                     ▼
              Behavior              Structure
                 │                     │
            Strategy/State       Adapter/Facade
                 │                     │
                 └──────────┬──────────┘
                            ▼
                       Type Safety
                            │
            ┌───────────────┼────────────────┐
            ▼               ▼                ▼
         Generics        Unions           Utilities
            │               │                │
            ▼               ▼                ▼
         keyof          Result Type      Mapped Types
         infer          State Model      Conditional
         satisfies      Exhaustive       Record
            │               │                │
            └───────────────┼────────────────┘
                            ▼
                     SIMPLEER CODE
    

    Ключові висновки

    Шаблони найкраще функціонують як спільний словниковий запас: „Це адаптер“, „Ці поведінки можна замінити одна на іншу“, „Ці стани належать до об’єднання“, „Позначте ці ID брендом“ та, що найважливіше, „Ця абстракція ще не заслуговує на свою складність“. Хороший TypeScript оцінюється за наявністю у системі цих властивостей, а не за тим, наскільки розумними є його типи:

    Invalid states
         ↓
    become difficult to represent
    
    Changing implementations
         ↓
    don't break consumers
    Business rules
         ↓
    are visible in the types
    Shared behavior
         ↓
    is reusable without duplication
    Complexity
         ↓
    is isolated behind clear boundaries
    
    • Вибирайте шаблони за складністю, яку вони усувають, а не за знайомістю.
    • Віддавайте перевагу об’єднанням, типам Result та повним перевіркам замість необов’язкових полів та надій.
    • Перевіряйте дані на межах часу виконання; самі типи тут вас не захистять.
    • Використовуйте найпростішу абстракцію, яка враховує зміни, яких ви насправді очікуєте.