tRPC: сквозные типы без кодогенерации и чего это стоит
tRPC даёт сквозную типобезопасность без кодогена: клиент выводит весь тип API из `typeof appRouter`, и типы текут через провод на этапе компиляции. «Без кодогена» значит, что клиент и сервер делят тип (`import type`); цена — время вывода и лаги редактора.
Вы переименовываете серверную процедуру с getUser на getUserById и добавляете аргумент includeDeleted: boolean. Жмёте «сохранить». Ещё не трогая клиент, три места вызова в двух фронтенд-файлах загораются красным: Property 'getUser' does not exist, а единственное место, что её вызывало, теперь без нового аргумента. Кодогенерация не запускалась. OpenAPI-файл не перегенерировался. Никакого npm run gen. Клиент просто знал — потому что тип API клиента не отдельная копия, а вывод из типа роутера сервера, пересчитываемый на каждом нажатии клавиши. Это tRPC, и вся магия — система типов, которую вы уже изучили.
Весь трюк: typeof appRouter
На сервере вы строите роутер. Его тип — не значение — кодирует каждое имя процедуры, вход и выход. Вы экспортируете этот тип и отдаёте клиенту как дженерик:
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUserById: t.procedure
.input(z.object({ id: z.number(), includeDeleted: z.boolean() }))
.query(({ input }) => findUser(input.id)), // возвращает User
});
export type AppRouter = typeof appRouter; // ^? тип всего роутера// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/router"; // только тип — см. ниже
const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/trpc" })] });
const user = await trpc.getUserById.query({ id: 42, includeDeleted: false });
// ^? User — выведено сквозь из типа возврата сервера
// trpc.getUser // Error: Property 'getUser' does not existcreateTRPCClient<AppRouter> берёт тип роутера сервера и через большой внутренний mapped/conditional-тип превращает его в интерфейс вызовов клиента: процедура getUserById.query роутера appRouter становится trpc.getUserById.query(input): Promise<output>. Тип входа берётся из z.infer вашей zod-схемы; тип выхода — из типа возврата резолвера. Оба текут к клиенту без единого сгенерированного файла. (В реальном приложении вы редко вызываете .query напрямую — вы прогоняете его через клиентский кэш вроде React Query, где эти выведенные типы доходят до ваших хуков; см. трек Frontend про клиентские кэши.)
▸Почему это работает
Тяжёлую работу здесь делает typeof в пространстве типов (оператор из раздела 04). typeof appRouter поднимает рантайм-значение (объект роутера) в тип, который клиент может потребить. Клиент никогда не импортирует рантайм роутера — только его тип — так что обращения сервера к БД, секреты и зависимости не попадают в бандл браузера. Контракт — это тип; реализация остаётся дома.
«Без кодогена» — что это реально значит и жёсткие пределы
Прежде чем брать tRPC в следующий проект, спросите себя: может ли клиент всегда сделать import type с сервера? Если да — живой вывод достаётся бесплатно. Если нет — нужен контракт провода, и это меняет всё.
REST + OpenAPI генерирует файл client.ts из спеки; GraphQL (язык запросов с типизированной схемой) генерирует типы из схемы. tRPC не генерирует ничего. Размен в том, что клиент обязан мочь сделать import type серверного AppRouter — а это значит:
- Одна сборка TypeScript / монорепо. Клиент и сервер компилируются вместе (или сервер поставляет
.d.ts, который потребляет клиент). Формата провода, описывающего API, нет; тип — это контракт, а тип может пересечь только границу компиляции, не границу сети или языка. - TypeScript на обоих концах. Swift- или Kotlin-клиент не может потребить TS-тип. Для полиглот- или сторонних потребителей нужен настоящий контракт провода — OpenAPI или GraphQL — и именно тогда их кодогенерация окупается.
Цена: время вывода роутера и лаги редактора
За удобство платят работой компилятора. Тип клиента — функция от всего типа роутера сервера, пересчитываемая tsc и языковым сервисом редактора. На роутере с сотнями процедур, глубоко вложенными саброутерами и сложными zod-схемами вы это чувствуете: всплывающие подсказки крутятся, автодополнение лагает, прогоны tsc ползут. Вывод не кэшируется так, как кэшировался бы сгенерированный файл — файла нет. Смягчения:
- Разбивайте на саброутеры и избегайте одного монолитного роутера на 500 процедур; меньшие единицы перевыводятся быстрее.
- Аннотируйте типы возврата процедур явно, чтобы тип выхода резолвера не выводился каждый раз сквозь весь слой данных.
- Держите zod-схемы разумными — гигантский
z.discriminatedUnionна 40 членов дорог в выводе везде, где используется. - Тянитесь к кодогену, когда вывод перестаёт масштабироваться: это настоящая точка, где модель OpenAPI/GraphQL «сгенерировал раз — прочитал файл» выигрывает по производительности компилятора, разменивая живой вывод на шаг сборки.
Вместе эти смягчения сокращают перевыводимую поверхность; без них монолитный роутер на 500 процедур заставляет весь языковой сервис ползти на каждом нажатии клавиши.
Главный самострел: случайный импорт серверного рантайма
Поскольку сервер и клиент живут в одном репо, легко написать import { appRouter } (значение) вместо import type { AppRouter } (тип). Импорт значения тащит весь серверный рантайм — драйверы БД, секреты, Node-only-модули — в бандл клиента и даже может отправить учётные данные в браузер. Всегда import type для роутера на клиенте. Включите "verbatimModuleSyntax": true (раздел 07), чтобы случайный импорт-значением типа был ошибкой компиляции, а не тихим раздуванием бандла.
tRPC даёт «сквозную типобезопасность без кодогена». Что «без кодогена» требует взамен?
Расставьте, как типы серверной процедуры доходят до типизированного места вызова клиента в tRPC:
- 1 Сервер строит appRouter; каждая процедура несёт zod-схему входа и тип возврата резолвера
- 2 Экспортировать ТИП роутера: type AppRouter = typeof appRouter
- 3 Клиент импортирует его через `import type` и передаёт: createTRPCClient<AppRouter>
- 4 mapped/conditional-тип превращает тип роутера в интерфейс клиента, выводя каждый вход/выход
- 5 Место вызова trpc.x.query(input) полностью типизировано; переименование серверной процедуры ломает клиент на этапе компиляции
- 01Механически: как клиент получает типы API в tRPC без кодогена?
- 02Что «без кодогена» вынуждает взамен и когда лучше предпочесть кодоген OpenAPI/GraphQL?
- 03Почему гигантский tRPC-роутер замедляет редактор и в чём самострел импорта рантайма?
Теперь вы понимаете движок за заявлением tRPC «без кодогена»: typeof appRouter поднимает роутер в тип, клиент выводит из него весь свой API на этапе компиляции, цена — связанность через общий тип плюс стоимость вывода, а самострел — импорт значением, утекающий сервер в браузер. Вы видели три точки на спектре граничной типизации — контракты, смоделированные руками, и Result-типы; рантайм-валидация zod; и вывод tRPC на этапе компиляции. Этот капстоун потребления типизированных систем готовит обратный навык: в следующем уроке вы будете проектировать generic-API библиотек, которые так же красиво выводятся для ваших вызывающих, и всё сходится в капстоуне типизированной фичи в разделе 09. Теперь, когда встретите на клиенте import { appRouter } в tRPC-проекте, вы знаете: надо менять на import type { AppRouter } — иначе секреты сервера окажутся в бандле браузера.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.