TypeScript Типобезопасные API-контракты: tRPC, OpenAPI, GraphQL и кодогенерация
0%

Типобезопасные API-контракты: tRPC, OpenAPI, GraphQL и кодогенерация

Типобезопасные API-контракты: tRPC, OpenAPI, GraphQL и кодогенерация

Внутри одного процесса TypeScript почти безупречен: переименовали поле — компилятор показал все 40 мест. Но ровно на границе процесса эта сеть обрывается. Сервер отдал { userName }, клиент читает { user_name } — оба проекта компилируются зелёными, а падает пользователь. Причина знакома с первой главы: типы стираются, по сети едут байты, а клиент и сервер собираются разными компиляторами в разное время.

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

Три способа иметь один контракт

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

Два правила, без которых пакет контрактов вредит:

  1. DTO — не доменная модель. Не экспортируйте наружу типы сущностей из гексагонального ядра: любое внутреннее переименование станет ломающим изменением API. Контракт — отдельный, намеренно стабильный слой.
  2. Версионируйте пакет. Общий тип, который меняется без версии, — это скрытая поломка для всех потребителей, кто деплоится не одновременно с вами.

Кодогенерация как часть 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 | nullstring), новое обязательное поле на входе, новое значение 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 и отсутствующих схем ответов) — дыра, которую никто не заметит.

Источники

Что дальше

Контракты, модули и классы — это про новый код. Но чаще всего TypeScript приходится вносить в уже существующий JavaScript-проект, где вдобавок начинает тормозить компилятор. Об этом — следующая глава.

Миграция JS → TS и производительность компилятора

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов