Главная / Статьи

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

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

Издатель: REACTAPP.TOP24 мин чтения5587 слов

Источники: From Design Patterns to Type-Level Architecture — A Practical Guide from 10+ Years of Production Experience

Большинство команд используют TypeScript для автодополнения и обнаружения опечаток, а затем открывают для себя его истинную ценность: он позволяет закодировать архитектурные решения так, чтобы компилятор обеспечивал их соблюдение. В этом руководстве рассматриваются классические объектно-ориентированные паттерны и техники на уровне типов, наиболее важные в крупных фронтенд- и полноценных full-stack-кодбазах, а также объясняется, как определить, стоит ли использовать тот или иной паттерн.

Рассматривайте систему типов как инструмент, способный отвечать на архитектурные вопросы:

  • Возможна ли вообще такая комбинация значений состояния?
  • Может ли эта 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;
    }
    

    Благодаря стратегии каждое правило превращается в класс, находящийся за общим интерфейсом:

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

    Затем функции могут требовать соответствующего вида идентификатора:

    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

    Атрибуты-варианты вместо логических флагов

    Независимые логические значения могут приводить к абсурду, например, к кнопке, которая одновременно является основной и опасной:

    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 из 2000 строк, как вы выбираете, какую абстракцию внедрить?
    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 и ещё одного поставщика.
    5. Как добавить слой репозитория в существующее приложение 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
    

    Шаблоны как общий словарный запас

    Шаблоны наилучшим образом функционируют как общий словарный запас: «это адаптер», «эти поведения взаимозаменяемы», «эти состояния принадлежат к одному классу», «присвойте метки этим идентификаторам», и, что особенно важно, «эта абстракция ещё не заслужила своей сложности». Качество хорошего 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: от классического ООП до современных хуков: объясняет, как классические шаблоны программного обеспечения, такие как Singleton, Factory и Observer, применяются в React, а также шаблоны, специфичные для React, вроде HOC, хуков и составных компонентов.