Типобезопасные API-контракты: tRPC, OpenAPI, GraphQL и кодогенерация
Внутри одного процесса TypeScript почти безупречен: переименовали поле — компилятор
показал все 40 мест. Но ровно на границе процесса эта сеть обрывается. Сервер
отдал { userName }, клиент читает { user_name } — оба проекта компилируются
зелёными, а падает пользователь. Причина знакома с
первой главы: типы стираются, по сети едут
байты, а клиент и сервер собираются разными компиляторами в разное время.
Задача этой главы — сделать так, чтобы контракт был один, а типы обеих сторон из него выводились, а не переписывались руками.
Три способа иметь один контракт
в том числе не на TS"] end subgraph CF["3. Code-first"] C1["Схемы Zod / TypeBox
в коде сервера"] -->|генерация| C2["OpenAPI-документ"] C1 -->|infer| C3["Типы сервера"] C2 -->|codegen| C4["Типы клиента"] end
| Type-first (tRPC) | Schema-first (OpenAPI, SDL, protobuf) | Code-first (Zod/TypeBox → OpenAPI) | |
|---|---|---|---|
| Источник правды | код сервера | файл спецификации | схемы валидации в коде |
| Кросс-язычность | нет, только TS | да | да, через сгенерированный документ |
| Внешние потребители | неудобно | естественно | естественно |
| Рантайм-валидация | своя (Zod) | своя или из схемы | встроена |
| Скорость итерации | максимальная | ниже: сначала правим спеку | высокая |
| Риск | привязка к монорепо | спека расходится с кодом | документ вторичен и может отставать |
Type-first: tRPC
tRPC убирает кодогенерацию вовсе: клиент импортирует тип роутера сервера и получает полностью типизированные вызовы. Работает это потому, что импорт типа стирается — в бандл клиента ничего серверного не попадает.
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string().uuid() })) // валидация входа в рантайме
.query(async ({ input }) => {
return userService.findById(input.id); // тип выхода выводится отсюда
}),
createOrder: t.procedure
.input(z.object({ items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })) }))
.mutation(async ({ input }) => orderService.create(input)),
});
// Экспортируем ТОЛЬКО тип — ни строчки серверного кода не уедет к клиенту
export type AppRouter = typeof appRouter;
// client/api.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/router.js"; // import type — стирается
const api = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/trpc" })] });
const user = await api.getUser.query({ id: crypto.randomUUID() });
// user типизирован тем, что реально возвращает сервер
// api.getUser.query({ id: 42 }); // ошибка компиляции: id должен быть строкой
Когда брать: монорепо, где клиент и сервер деплоятся вместе, обе стороны на TypeScript, внешних потребителей нет. Тогда tRPC даёт лучший цикл обратной связи в индустрии — переименование процедуры мгновенно ломает клиента в редакторе.
Ограничения честные: нет спецификации для чужих команд, нет версионирования контракта как артефакта, публичное API так не отдают. И главный подвох — тип выводится из реализации: случайное добавление поля в возврат немедленно становится частью контракта.
Schema-first: OpenAPI
Для публичных и межкомандных API источником правды остаётся спецификация.
Для TypeScript ключевой инструмент —
openapi-typescript: он превращает документ в типы, а
openapi-fetch даёт типизированный клиент
поверх fetch без рантайм-обёрток.
# генерируем типы из спеки (локального файла или URL) — шаг в CI и в pre-commit
pnpm dlx openapi-typescript ./openapi.yaml -o ./src/generated/api.d.ts
import createClient from "openapi-fetch";
import type { paths } from "./generated/api.js";
const client = createClient<paths>({ baseUrl: "https://api.acme.dev" });
// путь, метод, параметры и форма ответа проверяются компилятором
const { data, error } = await client.GET("/orders/{orderId}", {
params: { path: { orderId: "o_42" } },
});
if (error) {
// error типизирован схемой ошибок из спеки
logger.error({ status: error.code }, "не удалось получить заказ");
} else {
data.items.forEach((i) => console.log(i.sku));
}
Критично помнить: сгенерированные типы — это статическое обещание, а не проверка. Если сервер нарушит собственную спеку, компилятор об этом не узнает. На критичных путях всё равно валидируйте ответ схемой — ровно так, как разбирали в главе про идиомы. Хороший компромисс: типы из спеки для всего API + Zod-валидация для 5–10 самых важных ответов.
Code-first: схема как единственный источник
Третий путь снимает главный недостаток первых двух: схема живёт в коде рядом с обработчиком, из неё одновременно получаются рантайм-валидация, статические типы и OpenAPI-документ. Для Fastify это делается через type provider.
import Fastify from "fastify";
import { Type, type Static } from "@sinclair/typebox";
import type { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
const CreateOrder = Type.Object({
items: Type.Array(Type.Object({
sku: Type.String({ minLength: 1 }),
qty: Type.Integer({ minimum: 1 }),
}), { minItems: 1 }),
});
const OrderView = Type.Object({
id: Type.String({ format: "uuid" }),
total: Type.Number(),
status: Type.Union([Type.Literal("placed"), Type.Literal("paid")]),
});
type CreateOrderDto = Static<typeof CreateOrder>; // статический тип из той же схемы
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
app.post("/orders", {
schema: { body: CreateOrder, response: { 201: OrderView } },
}, async (req, reply) => {
// req.body уже провалидирован И типизирован — приведения не нужны
const order = await orders.create(req.body);
return reply.code(201).send(order);
});
Что вы получаете разом:
- валидацию входа до попадания в обработчик (400 отдаёт фреймворк);
- типы без дублирования (
Static<typeof Schema>); - ускоренную сериализацию ответа: Fastify компилирует схему ответа в быстрый сериализатор и заодно отсекает лишние поля — это защита от случайной утечки внутренних данных;
- OpenAPI-документ через
@fastify/swagger— автоматически из тех же схем.
TypeBox против Zod здесь: TypeBox является JSON Schema (нулевая стоимость конвертации, нативен для Fastify), Zod удобнее для сложных доменных правил и трансформаций. Часто их комбинируют: TypeBox на HTTP-границе, Zod внутри.
GraphQL и gRPC — одной строкой каждый
GraphQL: контракт — это SDL-схема. Типы генерирует
GraphQL Code Generator, причём как для
резолверов сервера, так и для документов клиента (TypedDocumentNode даёт вывод
типов прямо из текста запроса). Плюс — клиент запрашивает только нужные поля;
минус — сложность кеша, N+1 и авторизации на уровне полей.
gRPC/protobuf: контракт — .proto, генерация через
buf + ts-proto или @bufbuild/protobuf. Естественный выбор
для внутренней связи между сервисами на разных языках; для браузера нужен
gRPC-Web или Connect.
Общий пакет контрактов в монорепо
Даже без tRPC часть контракта полезно вынести в отдельный пакет — тогда обе стороны зависят от него явно, а не «по памяти».
packages/
├── contracts/ # схемы Zod/TypeBox + выведенные типы, без бизнес-логики
│ ├── src/order.ts
│ └── package.json # exports с условием "types" первым (см. главу 09)
├── api/ # сервер зависит от contracts
└── web/ # клиент зависит от contracts
Два правила, без которых пакет контрактов вредит:
- DTO — не доменная модель. Не экспортируйте наружу типы сущностей из гексагонального ядра: любое внутреннее переименование станет ломающим изменением API. Контракт — отдельный, намеренно стабильный слой.
- Версионируйте пакет. Общий тип, который меняется без версии, — это скрытая поломка для всех потребителей, кто деплоится не одновременно с вами.
Кодогенерация как часть CI
Главная опасность любой генерации — расхождение: спеку поменяли, типы не перегенерировали. Лечится проверкой «сгенерированное совпадает с закоммиченным».
# .github/workflows/contracts.yml (фрагмент)
- run: pnpm codegen # перегенерировать типы из спеки
- run: git diff --exit-code src/generated # упасть, если результат отличается
- run: pnpm typecheck # проверить, что код пережил изменение
- run: pnpm dlx oasdiff breaking base.yaml head.yaml # детектор ломающих правок
Держать ли сгенерированный код в git — вечный спор. Практика: коммитить, если
типы нужны для ревью и работы редактора без прогона генератора (обычный случай для
.d.ts), и не коммитить, если артефакт большой и полностью детерминирован.
В любом случае помечайте каталог как generated в .gitattributes, чтобы он не
шумел в диффах, и запрещайте править его руками.
Эволюция контракта
Контракт живёт годами, и главный навык — менять его, не ломая потребителей.
- Аддитивные изменения безопасны: новое опциональное поле, новый эндпоинт, новое значение enum на выходе — если клиенты игнорируют незнакомое.
- Ломающие: удаление или переименование поля, ужесточение типа
(
string | null→string), новое обязательное поле на входе, новое значение enum на входе для старого сервера. - Deprecation вместо удаления: пометьте поле
@deprecated(это видно и в OpenAPI, и в редакторе через JSDoc), дайте потребителям срок, соберите метрику использования — и только потом удаляйте. - Расширяемые типы на приёме:
status: "paid" | "shipped" | (string & {})— трюк, который сохраняет автодополнение, но не падает на незнакомом значении.
/** @deprecated используйте `customer.email`; будет удалено в v3 */
export interface OrderViewV2 {
id: string;
email?: string;
customer: { email: string };
}
Проверять совместимость нужно автоматически: oasdiff для OpenAPI,
graphql-inspector для SDL, buf breaking для protobuf. А чтобы убедиться, что
потребители действительно работают с новой версией, применяют
контрактное тестирование (Pact и подобные) — подробно оно разобрано в треке
тестирования. Как клиентская
сторона потребляет такие контракты в браузере, показано в
главе про загрузку данных.
Типичные ошибки
- Тип продублирован руками на клиенте «по мотивам» ответа сервера. Это не контракт, это совпадение, которое обязательно разъедется.
- Кодоген не в CI. Локально сгенерировали, забыли — типы врут.
- Доверять сгенерированным типам как проверке. Они статические; сервер может их нарушить.
- Экспорт доменных типов как API-типов — внутренние изменения становятся публичными.
- Спека, которую пишут после реализации. Тогда она документация, а не контракт; переходите к code-first, чтобы документ генерировался.
anyв сгенерированном коде (частое следствиеadditionalProperties: trueи отсутствующих схем ответов) — дыра, которую никто не заметит.
Источники
- tRPC — концепция и ограничения.
- openapi-typescript и openapi-fetch — генерация типов из OpenAPI и типизированный клиент.
- Fastify: Type Providers и TypeBox.
- GraphQL Code Generator и buf — codegen для GraphQL и protobuf.
- oasdiff — детектор ломающих изменений в OpenAPI.
- Zalando RESTful API Guidelines — зрелый свод правил эволюции HTTP-API.
Что дальше
Контракты, модули и классы — это про новый код. Но чаще всего TypeScript приходится вносить в уже существующий JavaScript-проект, где вдобавок начинает тормозить компилятор. Об этом — следующая глава.