Между фронтендом и бэкендом всегда есть договор: какие есть ручки, что они принимают и что возвращают. Вопрос только в том, где этот договор записан. Чаще всего — нигде: бэкендер сказал в переписке, фронтендер написал тип руками. Работает ровно до первого изменения.
Разберём, что происходит при таком подходе и как выглядит альтернатива, где типы, клиент и моки берутся из описания 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 и обновил спецификацию. Дальше:
- При следующей сборке генератор перезаписывает типы.
- TypeScript падает во всех местах, где используется
data.price. - Разработчик видит список этих мест до того, как код попадёт к пользователю.
Вместо «упало в бою через две недели» получается «не собралось за десять секунд». Приём тот же, что даёт сгенерированная модель базы данных на бэкенде: сломанный контракт становится ошибкой компиляции.
Сгенерированное не хранят в репозитории
Соблазн закоммитить сгенерированные файлы велик: их видно, они не требуют лишнего шага при установке. Но тогда появляется вторая копия правды — и она начинает жить своей жизнью: кто-то поправил сгенерированный файл руками, кто-то забыл перегенерировать после обновления спецификации, и в ветке лежит уже не то, что описано в контракте.
Поэтому генерацию ставят шагом перед сборкой и тестами, а сами файлы добавляют в .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.
- Тестирование фронтенда — где в пирамиде тестов стоят проверки с подменой сети.
- Обработка ошибок — что показывать, когда запрос всё-таки не удался.