← назад к разделу

Между фронтендом и бэкендом всегда есть договор: какие есть ручки, что они принимают и что возвращают. Вопрос только в том, где этот договор записан. Чаще всего — нигде: бэкендер сказал в переписке, фронтендер написал тип руками. Работает ровно до первого изменения.

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

Чем плох тип, написанный руками

Выглядит это обычно так:

// src/shared/api/types.ts — написано руками по образцу ответа
export interface Product {
  id: string;
  title: string;
  price: number;
  available: number;
}

export async function getProduct(id: string): Promise<Product> {
  const response = await fetch(`/api/v1/products/${id}`);
  return response.json();          // тут ложь: никто не проверял, что пришло
}

Проблема не в том, что это долго писать. Проблема в том, что этот тип ничем не связан с реальностью. response.json() возвращает any, и мы просто объявляем: считай, что это Product. TypeScript поверит.

Дальше происходит обычное: бэкенд переименовывает price в priceRub, или превращает число в строку, чтобы не терять копейки, или добавляет обязательное поле. Компилятор молчит — он видит наш тип, а не ответ сервера. Ошибка всплывает в браузере у пользователя: undefined ₽ на карточке товара.

Ровно та же история, что с внешними событиями на бэкенде: пока контракт нигде не записан, каждая сторона верит в свою версию — и однажды они расходятся.

Описание API как источник правды

Альтернатива: у API есть машиночитаемое описание — спецификация OpenAPI. Это обычный YAML-файл, в котором перечислены ручки, параметры и схемы ответов:

paths:
  /api/v1/products/{productId}:
    get:
      operationId: getProduct
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Карточка товара
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductDto'

components:
  schemas:
    ProductDto:
      type: object
      required: [id, title, price, available]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        price: { type: string, description: "Рубли и копейки строкой" }
        available: { type: integer }

Файл этот не фронтендер придумывает: его отдаёт бэкенд — либо пишет руками до реализации, либо отдаёт сгенерированным из кода. Важно, что он один на две стороны.

Из такого описания генератор делает три вещи сразу.

Типы. Ровно те, что описаны в схеме, без ручного переписывания:

export interface ProductDto {
  id: string;
  title: string;
  price: string;      // строка, а не число — как в контракте
  available: number;
}

Клиент и хуки. Функцию запроса и, если нужно, готовый хук поверх TanStack Query:

import { useGetProduct } from "@/shared/api/generated/products";

export function ProductCard({ id }: { id: string }) {
  const { data, isPending, isError } = useGetProduct(id);

  if (isPending) return <Skeleton />;
  if (isError) return <ErrorBox />;

  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.price} ₽</p>
    </article>
  );
}

Моки. Те же схемы превращаются в заглушки для тестов — данные придумываются по типам полей, а не пишутся руками в каждом тесте.

Что меняется, когда бэкенд правит контракт

Это главный выигрыш, ради которого всё затевается. Бэкенд переименовал price в priceRub и обновил спецификацию. Дальше:

  1. При следующей сборке генератор перезаписывает типы.
  2. TypeScript падает во всех местах, где используется data.price.
  3. Разработчик видит список этих мест до того, как код попадёт к пользователю.

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

Сгенерированное не хранят в репозитории

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

Поэтому генерацию ставят шагом перед сборкой и тестами, а сами файлы добавляют в .gitignore:

{
  "scripts": {
    "generate:api": "orval --config ./orval.config.ts",
    "predev": "bun run generate:api",
    "prebuild": "bun run generate:api",
    "pretest": "bun run generate:api"
  }
}

Так у любого разработчика и на сборочной машине клиент всегда соответствует текущей спецификации — потому что делается заново каждый раз.

Тесты с моками из той же спецификации

Отдельная выгода: заглушки для тестов тоже растут из контракта. Инструмент перехвата сетевых запросов (MSW) подменяет ответы, а тела ответов берутся из сгенерированных моков:

import { setupServer } from "msw/node";
import { getProductsMock } from "@/shared/api/generated/products.msw";

const server = setupServer(...getProductsMock());

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

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

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

Чего генерация не делает

Стоит понимать границы, иначе будут завышенные ожидания.

  • Она не проверяет, что сервер соблюдает контракт. Если в спецификации написано «строка», а приходит число, типы всё равно скажут «строка». Спецификация — договор, а не проверка; проверять данные на входе всё равно приходится, если источнику нет полного доверия.
  • Она не заменяет слой приложения. Сгенерированные хуки — это транспорт. Собирать из них экран, решать, что показывать при ошибке, и складывать данные в состояние по-прежнему нужно самому.
  • Она плохо переживает плохую спецификацию. Если у ручек нет operationId, схемы описаны как object без полей, а половина ответов — any, генератор честно сделает бесполезный клиент. Тогда чинить надо описание, а не генератор.

Коротко

  • Тип, написанный руками по образцу ответа, ничем не связан с сервером: он врёт молча, а ошибка всплывает у пользователя.
  • Описание OpenAPI — один договор на две стороны, и его отдаёт бэкенд.
  • Из описания генерируются типы, клиент с хуками и моки для тестов.
  • Изменение контракта становится ошибкой компиляции: список мест, которые надо поправить, виден сразу.
  • Сгенерированное не хранят в репозитории — генерацию ставят шагом перед запуском, сборкой и тестами.
  • Моки из той же спецификации делают тесты честнее: они ломаются вместе с контрактом.
  • Генерация не проверяет, что сервер соблюдает договор, и не заменяет слой приложения.

Что почитать дальше

  • Загрузка данных в React — как устроены хуки запросов, которые генерируются поверх TanStack Query.
  • Тестирование фронтенда — где в пирамиде тестов стоят проверки с подменой сети.
  • Обработка ошибок — что показывать, когда запрос всё-таки не удался.