Головна / Статті

Шаблони безпеки типів у TypeScript: генерики, інжектування залежностей та коли шаблон виправдовує себе

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

Видавець: REACTAPP.TOP24 хв читання5587 слів

Джерела: From Design Patterns to Type-Level Architecture — A Practical Guide from 10+ Years of Production Experience

Більшість команд використовують 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:
    

    «Це вимагає стратегії». Перш ніж її застосовувати, запитайтеся, чи справді ці гілки є взаємозамінними алгоритмами під один контракт. Якщо так, то стратегія підходить. Якщо ж справжня проблема полягає у тому, що деякі поля мають сенс лише для певних методів, тоді кращим інструментом може стати дискримінована унія, яка робить недійсні комбінації непредставленими. Дізнатися про каталог легко; справжнім навиком є прив’язка запису до конкретного сценарію використання.

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

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

    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-запиті.

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

    Адаптер: стабільний контракт навколо чужого 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;
    }
    

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

    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
    

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

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

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

                     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-and-optionals:

    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";
      };
    

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

    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
    

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

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

    Патерн стану чи дискримінована unія?

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

    Draft
    Paid
    Shipped
    Cancelled
    

    Союз, який захищає від дискримінації, може бути саме тим, що вам потрібно:

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

    Але коли кожен стан має суттєву поведінку:

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

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

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

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

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

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

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

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

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

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

    PaymentFactory.create("stripe");
    

    Стратегія відповідає на запитання «яку поведінку слід виконати?»:

    new Order(discountStrategy);
    

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

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

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

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

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

    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;
    };
    

    Генеричний об’єкт bus над цією картою пов’язує кожну назву з її вмістом за допомогою 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. Як ви можете визначити, що абстракція створена передчасно?
    4. Коли краще використовувати композицію замість успадкування?
    5. Як запобігти тому, щоб 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 та ще одного постачальника.
  • Як додати шар Repository до існуючого React-додатку без повної переробки?
  • Як описати події WebSocket із різними навантаженнями даних?
  • Як запобігти передачі 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 та повним перевіркам замість необов’язкових полів та сподівань.
    • Перевіряйте дані на межах часу виконання; самі типи там вас не захистять.
    • Використовуйте найпростішу абстракцію, яка враховує зміни, яких ви насправді очікуєте.

    Пов’язана література

  • Шаблони дизайну React: від класичного OOP до сучасних хуків: пояснює, як класичні шаблони програмного забезпечення, такі як Singleton, Factory та Observer, застосовуються в React, а також шаблони, специфічні для React, як-от HOCs, хуки та складові компоненти.