Главная / Статьи / Выбор шаблонов TypeScript в зависимости от сложности проблем, которые они решают.

Выбор шаблонов TypeScript в зависимости от сложности проблем, которые они решают.

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

5991 слов

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

    «Здесь нужен шаблон Strategy». Прежде чем его использовать, спросите себя, действительно ли эти варианты представляют собой взаимозаменяемые алгоритмы под одним контрактом. Если да, то шаблон Strategy подойдет. Если же проблема в том, что некоторые поля имеют смысл только для определенных методов, то более подходящим инструментом может стать дискриминированное соединение, не позволяющее представлять невалидные комбинации. Знать каталог элементов легко; настоящим навыком является соотнесение элемента с конкретной ситуацией.

    Шаблоны создания

    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-запросе.

    Структурные шаблоны

    Adapter: стабильный интерфейс для API другого приложения

    Адаптер является одним из наиболее часто используемых паттернов в коде приложений. Предположим, ваш код ожидает следующий внутренний контракт:

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

    в то время как SDK от стороннего поставщика предоставляет метод с другим именем:

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

    Простой адаптер реализует ваш интерфейс и преобразует вызовы:

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

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

    PaymentGateway
    

    а не от каждого поставщика, стоящего за ним:

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

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

    Фасад: один вызов для многоэтапного процесса

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

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

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

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

    Фасад выполняет эту организацию один раз:

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

    И компонент сводится к одному вызову:

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

    Это особенно полезно, когда важен порядок выполнения шагов.

    Декоратор: добавление поведения путем обертки

    Декоратор добавляет поведение без изменения исходной реализации, поскольку обертка и объект, который она оборачивает, имеют общий интерфейс. Контракт:

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

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

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

    Декоратор, который хранит любой объект типа Logger и добавляет в сообщения префикс с временем:

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

    Обертка — это просто процесс создания объекта:

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

    Поскольку каждый декоратор сам по себе является объектом типа Logger, они могут накапливаться друг на друга:

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    Та же идея реализуется под другими названиями:

    • цепочки промежуточных компонентов
    • функции-обертки
    • компоненты высшего порядка в React
    • логирование
    • уровни кэширования
    • проверки авторизации
  • инструментализация и отслеживание
  • Паттерны поведения

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

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

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

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

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

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

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

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

    Наблюдатель: уведомления от одного к многим

    Механизм наблюдателя позволяет одному объекту-субъекту уведомлять любое количество объектов-слушателей при изменении ситуации:

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

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

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

    Эта функция для очистки отвечает за управление жизненным циклом. Если её не вызвать, это приведёт к:

    • утечкам памяти из-за слушателей, которые существуют дольше своих владельцев
    • дублированию обработки при двукратной привязке слушателя
    • использованию устаревших значений в результате работы замыканий
    • возникновению побочных эффектов после уничтожения компонента

    В React именно это возвращается из обратного вызова функции useEffect.

    Команда: действия в виде объектов

    Команда преобразует действие в объект с единым интерфейсом:

    interface Command {
      execute(): void;
    }
    

    Конкретные команды реализуют этот интерфейс:

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

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

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

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

    Шаблоны данных и композиции

    Repository: изоляция доступа к данным

    Когда доступ к данным становится сложным, Repository создает контракт между бизнес-логикой и тем элементом, который хранит или загружает данные:

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

    Контракт описывает, о чем может просить приложение:

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

    Одна из реализаций взаимодействует с REST API:

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

    Бизнес-код зависит от интерфейса:

    UserRepository
    

    а не от способа передачи данных:

    fetch()
    axios()
    graphqlClient()
    

    Это важно, когда меняется исходный код: переход с REST на GraphQL, добавление кэша IndexedDB или использование временных данных в памяти для тестирования. Обратите внимание, что response.json() возвращает значение без указания типа, поэтому репозиторий также является подходящим местом для проверки ответов.

    Композиция вместо наследования

    В работе с фронтендом композиция важнее любых паттернов, основанных на наследовании. Вместо одного компонента, который контролирует всё:

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

    разделяйте ответственности на более узкие части:

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

    В React это достигается простым вложением компонентов:

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

    Каждая часть может быть понята, протестирована и заменена отдельно. Подробнее о разбиении крупных компонентов см. как исправить перегрузку свойств в React с помощью композиции и слотов.

    Моделирование состояния с помощью системы типов

    Начиная с этого момента, сам TypeScript является архитектурным инструментом.

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

    Запрос проходит через этапы, на которых обрабатываются разные данные. Дискриминированный союз предоставляет каждому этапу свою собственную структуру, связанную общим полем status:

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

    Использование дискриминанта позволяет узко определить тип в каждом ветке:

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

    Компилятор отслеживает, какие поля существуют после каждой проверки:

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

    Сравните с распространенной моделью boolean-и-опциональных значений:

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

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

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

    Союз затрудняет выражение подобных комбинаций. Лучше моделировать допустимые состояния, вместо того чтобы разбрасывать опциональные свойства и полагаться на то, что все правильно их объединят.

    Типы результата для явных ошибок

    У многих операций есть ровно два возможных исхода:

    Success
       OR
    Failure
    

    Тип Result описывает оба исхода с использованием логического параметра:

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

    Функция, возвращающая этот тип:

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

    Вызывающий код должен проверить значение success перед обращением к полям data или error:

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

    Это подходит для ожидаемых сбоев в бизнес-логике, таких как «электронная почта уже зарегистрирована». Исключения же предназначены для настоящих ошибок и сбоев инфраструктуры; тип Result просто делает ожидаемые сбои видимыми в сигнатуре функции.

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

    Генерики сохраняют связь между входными и выходными данными

    Использование типа any приводит к потере информации:

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

    Параметр типа сохраняет эту информацию:

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

    Выводимый результат соответствует аргументам функции:

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

    Связь между входными и выходными данными сохраняется:

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

    Генерики также позволяют повторно использовать общие контракты. Одна оболочка ответа:

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

    описывает множество полезных нагрузок:

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

    Ограничения: требование к форме

    Это не компилируется:

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

    потому что TypeScript не знает, что у T есть поле id. Ограничение добавляет такую гарантию:

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

    Принимается любой объект с строковым полем id, включая дополнительные поля:

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

    Читайте ограничение следующим образом:

    T can be anything
    BUT
    T must have id: string
    

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

    Учитывая интерфейс:

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

    keyof возвращает объединение имен его свойств:

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

    Сочетание keyof с вторым параметром типа и типом индексированного доступа T[K] приводит к созданию оператора доступа, тип возвращаемого значения которого совпадает с ключом:

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

    Реальный ключ компилируется:

    getProperty(user, "name");
    

    Отсутствующий ключ вызывает ошибку компиляции:

    getProperty(user, "salary");
    

    Преимущество заключается в совместной работе трех инструментов:

    Generics
       +
    keyof
       +
    Indexed Access
    

    Типы-карты: преобразование существующего типа

    Типы-карты перебирают ключи типа для создания нового типа. Начиная с:

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

    можно получить версию с полностью опциональными полями:

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

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

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

    Так определяются встроенные конструкции, такие как Partial.

    Условные типы: принятие решений на уровне типа

    Условные типы выбирают между двумя типами на основе возможности присвоения:

    T extends U ? X : Y
    

    Этот инструмент разбирает типы элементов массива и оставляет всё остальное без изменений:

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

    На этом этапе система типов ведёт себя как небольшой язык на этапе компиляции, что требует осторожности.

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

    Внутри условного типа infer объявляет переменную типа, которую TypeScript заполняет путём сопоставления. Это переосуществляет встроенный ReturnType:

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

    Используйте его вместе с typeof, чтобы получить тип из существующей функции, тем самым предотвращая отклонения от её реализации:

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

    Определения типов в библиотеках в значительной степени зависят от этого.

    Сначала встроенные вспомогательные типы

    Прежде чем писать сложные вспомогательные функции, узнайте, что уже входит в состав языка:

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

    Удаление чувствительного поля осуществляется за одну строку, вместо использования дублированного интерфейса, из-за чего может наступить разногласие:

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

    Руководство по встроенным в TypeScript утилитным типам подробно рассматривает каждый из этих случаев.

    Record с замкнутым набором ключей

    Record подходит для поиска и настройки, особенно когда ключи берутся из литерального объединения:

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

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

    const permissions: Record<string, boolean>
    

    поскольку Record<string, boolean> принимает практически любой строковый ключ, поэтому пропуски и опечатки остаются незамеченными.

    Шаблоны безопасности для идентификаторов и ненадежных данных

    Типы с маркировкой для идентификаторов домена

    Два идентификатора могут быть строками, но означать разные вещи:

    const userId: string;
    const productId: string;
    

    С точки зрения структуры TypeScript не может отличить их друг от друга. Сложение типа string с фиктивным свойством создает разные типы:

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

    Затем функции могут требовать наличия именно определенного вида идентификатора:

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

    Передача ProductId там, где ожидается UserId, приводит к ошибке компиляции. Этот бренд никогда не существует во время выполнения; значения, связанные с брендом, создаются с помощью небольшого конструктора или функции валидации, которые выполняют приведение типов в одном месте. Концептуально:

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

    Это оправдано в крупных системах, где десятки идентификаторов используют один примитивный тип.

    Защитники типов для входных данных типа unknown

    Данные из сети, хранилища или ввода пользователя должны поступать в виде unknown:

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

    Приведение типов — это соблазнительный ускоритель:

    const user = data as User;
    

    Вместо этого используйте пользовательский тип-защитник для ограничения значения; его тип возврата value is User сообщает компилятору, что подтверждает результат true:

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

    Помните, что типы уничтожаются во время выполнения. Интерфейс вроде этого:

    interface User {
      id: string;
    }
    

    не проверяет ничего из того, что отправляет API. Приведенный выше защитник проверяет лишь наличие ключей, а не их типы; поэтому для ненадежных входных данных используйте TypeScript с валидатором схемы во время выполнения.

    Полная проверка с помощью never

    Одно из самых эффективных сочетаний в языке:

    Discriminated Union
            +
    never
            +
    switch
    

    Используйте союз статусов:

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

    и вспомогательную функцию, принимающую только never:

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

    Когда обрабатываются все элементы, фигура по умолчанию видит тип never, поэтому вызов компилируется:

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

    Теперь коллега расширяет объединение:

    Now imagine someone adds:"cancelled" to Status.
    

    По умолчанию используется значение "cancelled", которое нельзя присвоить значению never, в результате чего сборка завершается с ошибкой до тех пор, пока этот случай не будет обработан. Компилятор становится проверщиком проекта, запоминающим каждого потребителя.

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

    TypeScript позволяет создавать типы строковых литералов на основе других типов:

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

    Результатом становится объединение, включающее все возможные комбинации:

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

    Практические применения включают:

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

    as const при использовании значений в качестве источника правды

    Литерал массива строк расширяется:

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

    Его тип — string[]. Добавление as const приводит к созданию тупла из литералов с режимом чтения только:

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

    Из него напрямую следует тип объединения:

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

    Определите значения один раз и никогда не ведите ручное управление параллельным объединением.

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

    satisfies проверяет выражение по отношению к типу, не преобразуя его в этот тип:

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

    Объект проверяется по отношению к Config, однако config.environment сохраняет литеральный тип "production", который обычная аннотация потеряла бы. Это подходит для:

    • конфигурации маршрутов
    • флагов функций
    • дизайн-токенов
    • объектов статических настроек
    • карт разрешений

    Внедрение зависимостей

    Создание зависимостей внутри класса связывает его с ними:

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

    Получение их через конструктор сохраняет класс агностичным:

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

    В производственной среде передаётся настоящий клиент:

    const service =
      new UserService(apiClient);
    

    а в тестах — имитация:

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

    Это один из самых простых способов создания тестируемого кода, и для этого достаточно параметров конструктора.

    Различие похожих паттернов

    Паттерн состояний или дискриминированное соединение?

    Учитывая жизненный цикл заказа:

    Draft
    Paid
    Shipped
    Cancelled
    

    Возможно, достаточно дискриминированного соединения:

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

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

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

    более подходящим может оказаться паттерн состояний, при котором каждый объект состояния реализует разрешённые операции. Решайте, исходя из уровня сложности каждого состояния, а не по терминологии.

    Стратегия или состояния?

    Частая тема интервью. С помощью стратегии клиент выбирает алгоритм:

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

    С помощью состояния объект меняет своё поведение по мере прохождения жизненного цикла:

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

    Стратегия влияет на способ выполнения задачи; состояние определяет действия объекта на текущем этапе.

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

    Фабрика отвечает на вопрос «какой объект следует создать?»:

    PaymentFactory.create("stripe");
    

    Стратегия отвечает на вопрос «какое поведение следует применить?»:

    new Order(discountStrategy);
    

    Они естественным образом сочетаются: фабрика создаёт стратегию, выполняющую работу:

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    Применение шаблонов в React

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

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

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

    Союз вариантов позволяет каждому из них задавать собственные требования:

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

    Теперь вариант опасности должен указывать параметр confirmationRequired.

    Интерфейсы компонентов, отклоняющие противоречия

    Компонент, отображающий либо ссылку, либо кнопку с необязательными параметрами 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
    

    Хорошие шаблоны появляются тогда, когда становятся ясны требования архитектуры; их не навязывают заранее.

    Вопросы на собеседовании, проверяющие реальное понимание

    Вопрос «Что такое шаблон фабрики?» проверяет запоминание. Эти вопросы оценивают способность к принятию решений.

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

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

    1. Как моделировать запрос с состояниями загрузки, успеха, ошибки и повторной попытки?
    2. Как предотвратить недопустимые комбинации свойств React?
    3. В чём разница между unknown, any и never?
    4. Когда стоит выбрать type вместо interface, и наоборот?
    5. Как keyof взаимодействует с генериками?

    Расширенные возможности TypeScript:

    1. Какой конкретный случай использования существует для условных типов?
    2. Что делает функция infer?
    3. Что такое отображённый тип?
    4. Что такое дистрибутивные условные типы?
    5. Когда целесообразно использование брендированных типов?
    6. Какую проблему решает функция satisfies?
    7. Как as const влияет на процесс вывода заключений?

    Практическое проектирование:

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

    Откровенный вопрос: «Опишите момент, когда вы намеренно решили не использовать шаблон проектирования». Хороший ответ объясняет компромисс: при наличии единственной реализации и отсутствии вероятных изменений, которые требовали бы защиты, косвенное управление не снизило бы сложность, поэтому код оставался простым до тех пор, пока второй случай использования не стал этого оправданием.

    Заключительная модель мышления

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

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

    Основные выводы

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