Процедура вместо маршрута: @Router, @Query, @Mutation
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Роутер вы уже написали: процедура — это схема входа рядом с обработчиком, сам роутер — обычный объект, а клиент вырастает из его типа. Настоящий tRPC устроен так же, и в этой главе он наконец появляется на экране — вместе с боевым API автора, где процедур двенадцать, а контроллеров всего два. От миниатюры настоящий пакет отличается в трёх местах: процедура собирается цепочкой, у неё есть вид — запрос или мутация, — и от вида зависит метод запроса. А потом процедуры въедут внутрь Nest, и выяснится, что контейнер у них с контроллерами общий, а конвейер — нет. Выяснится не из документации: из вывода прогона.
В миниатюре процедура собиралась одним вызовом: procedure(схема, обработчик). В настоящем tRPC то же самое пишется цепочкой: t.procedure.input(схема).query(обработчик). Длиннее, и поначалу кажется, что это вкусовщина. Не вкусовщина: цепочка решает задачу, которой у миниатюры не было.
Задача такая. У половины процедур настройки общие: одни открыты гостю, другие требуют токена, третьи ещё и считают частоту вызовов. С тремя аргументами это пишется в каждой процедуре заново. С цепочкой — один раз: const protectedProcedure = t.procedure.use(проверкаТокена), и дальше от этой заготовки отходят десятки процедур. Чтобы так было можно, шаг обязан не править заготовку, а возвращать новую — иначе первая же процедура испортила бы её всем остальным.
Соберём такой строитель сами, поверх схем и call из типизированного клиента главы 6.
base — начало цепочки: схемы ещё нет, и вход у неё unknown. Каждый input отдаёт нового строителя, у которого параметр типа уже другой, а query цепочку закрывает: из строителя получается обычная процедура — та самая пара «схема и обработчик», которую умеет звать call. Заготовка при этом цела: count отошёл от того же base после двух процедур и получил свою схему, а не чужую.
Обработчику приезжает не сам вход, а объект { input }, и в миниатюре это лишняя пара скобок. В настоящем tRPC рядом с input лежат ctx, path, signal и номер вызова в пачке (@trpc/server 11.18.0, ProcedureResolverOptions). Добавить в объект новое поле можно, не тронув ни одной процедуры; добавить второй аргумент — только сломав все.
Последняя строка примера помечена @ts-expect-error, и пометка тут не для красоты: тип обработчика пришёл из схемы входа, а у объекта { id: число } поля title нет. Снимите пометку — компилятор скажет это вслух, хотя ни одного типа в обработчике не написано.
Цепочка — не мода на «текучие интерфейсы». Это единственный способ дать процедуре общую заготовку и не потерять тип входа: каждый шаг возвращает новый тип, а не правит старый.
Цепочка кончается не просто обработчиком, а видом: .query(), .mutation() или .subscription(). Маршрутизации вид не нужен — имя процедуры и так едет строкой, и роутер находит её по имени. Вид нужен другому: транспорту.
GET и POST различаются не буквами. GET браузер повторит сам, положит в кеш, покажет в адресной строке и даст на него ссылку; прокси запишет адрес в журнал целиком, вместе со входом. POST не повторяется, не кешируется, ссылкой не становится. Объявив процедуру запросом, вы всё это разрешаете; объявив мутацией — запрещаете. Поэтому вид и стоит в объявлении, а не выводится из того, что обработчик делает.
Обе половины сошлись: GET к мутации и POST к запросу отвергнуты до разбора входа, а несуществующее имя не доходит даже до проверки метода — по такому пути в роутере просто нет ветки. Ровно так отвечает и настоящий tRPC, только другими словами; его ответ будет ниже, в разделе про Nest.
Где едет вход, тоже решает вид. У запроса он уезжает в строке запроса — ?input={"id":1}, — у мутации в теле. Это не соглашение tRPC, а следствие метода: у GET тела нет.
В REST правку и удаление различают методами: PUT, PATCH, DELETE. У процедур различать нечем и незачем — их различает имя: notes.update и notes.remove одинаково мутации. Метод в tRPC остаётся ровно одним решением: читаю или меняю. Подписка стоит особняком — она держит соединение открытым, и в этой части её нет.
Из этого следует одна практическая мелочь. Вход запроса едет в адресе, а длину адреса ограничивают и сервер, и прокси между ним и клиентом. Процедура с большим входом — фильтр на сотню идентификаторов, пачка операций — живёт мутацией, даже если ничего не меняет.
Вид — первое, что в процедуре стоит объявить, и последнее, что стоит менять. Смена query на mutation меняет метод, а значит, и всё поведение кешей и ссылок вокруг.
Пакет @trpc/server можно потрогать, не поднимая ни порта, ни Nest. Точка сборки — initTRPC.create(): она отдаёт t.router и t.procedure, то есть ровно ваши router и procedure. А у собранного роутера есть createCaller(ctx) — вызвать процедуру прямо из кода, минуя и сеть, и разбор запроса.
import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; type Note = { id: number; title: string; archived: boolean }; const rows = new Map<number, Note>([[1, { id: 1, title: 'купить хлеб', archived: false }]]); let nextId = 2; // Схема без zod: весь договор — одно свойство «~standard» const trimmed = { '~standard': { version: 1 as const, vendor: 'свой валидатор', // Фантомное поле: в рантайме его нет, но тип входа компилятор читает только отсюда types: undefined as unknown as { input: { title: string }; output: { title: string } }, validate( value: unknown, ): { value: { title: string }; issues?: undefined } | { issues: { message: string; path: string[] }[] } { const title = typeof value === 'object' && value !== null ? (value as { title?: unknown }).title : undefined; if (typeof title !== 'string') return { issues: [{ message: 'ожидалась строка', path: ['title'] }] }; const text = title.trim(); return text === '' ? { issues: [{ message: 'не может быть пустым', path: ['title'] }] } : { value: { title: text } }; }, }, }; // Точка сборки: отсюда берутся и router, и procedure const t = initTRPC.create(); export const appRouter = t.router({ notes: t.router({ byId: t.procedure.input(z.object({ id: z.number().int().positive() })).query(({ input }) => { const note = rows.get(input.id); if (!note) throw new TRPCError({ code: 'NOT_FOUND', message: `заметки ${input.id} нет` }); return note; }), create: t.procedure.input(z.object({ title: z.string().min(1) })).mutation(({ input }) => { const note: Note = { id: nextId++, title: input.title, archived: false }; rows.set(note.id, note); return note; }), byTitle: t.procedure.input(trimmed).query(({ input }) => { const note = [...rows.values()].find((row) => row.title.includes(input.title)); if (!note) throw new TRPCError({ code: 'NOT_FOUND', message: `нет заметки со словом «${input.title}»` }); return note; }), }), }); export type AppRouter = typeof appRouter;
byId({ id: 1 }) → {"id":1,"title":"купить хлеб","archived":false}
create({ title: "молоко" }) → {"id":2,"title":"молоко","archived":false}
byId({ id: 99 }) → отказ NOT_FOUND | заметки 99 нет
класс: TRPCError | cause: нет
byId({ id: 0 }) → отказ BAD_REQUEST | id: Too small: expected number to be >0
класс: TRPCError | cause: ZodError
byTitle({ title: " хлеб " }) → {"id":1,"title":"купить хлеб","archived":false}
byTitle({ title: " " }) → отказ BAD_REQUEST | title: не может быть пустым
класс: TRPCError | cause: StandardSchemaV1Error
notes.byId: query
notes.create: mutation
notes.byTitle: queryДва отказа здесь устроены по-разному, и разница важнее их кодов. NOT_FOUND бросил обработчик — new TRPCError({ code, message }), и cause у него пустой. BAD_REQUEST поставил сам tRPC, до обработчика: вход не прошёл схему. Что обработчик при этом не звался, видно по самому выводу — вызов с нулевым id дошёл бы до rows.get(0) и кончился бы NOT_FOUND, а кончился иначе. Сами коды — доменные, а не статусы HTTP: их у tRPC двадцать один, и в статус каждый переводится отдельно.
Схем в примере две, и t.procedure.input принял обе. Первая — zod, вторая написана в файле руками: весь её договор с tRPC — одно свойство ~standard с функцией validate и фантомным полем типов, тот самый общий язык схем. А классы в cause у них разные, и это не мелочь: getParseFn (@trpc/server 11.18.0) сначала ищет у схемы её собственный разбор — parseAsync, parse, assert, — и только не найдя ни одного, зовёт ~standard.validate и заворачивает исход в свою ошибку. У zod parseAsync есть, поэтому приехал её родной ZodError; у рукописной схемы нет ничего, кроме договора, — и приехал StandardSchemaV1Error самого tRPC. Общий язык здесь не главный, а запасной: он для схем, чьего диалекта tRPC не знает.
Ещё одна строка стоит того, чтобы на неё посмотреть дважды: поиск по слову с пробелами по краям нашёл заметку. До обработчика доехал не исходный вход, а разобранный — тот, что вернула схема. Проверка и приведение здесь одно действие, как и в вашей трубе.
Последние три строки — роутер как значение. appRouter._def.procedures — плоская таблица «полное имя → процедура», собранная из вложенных t.router; вид лежит в _def.type самой процедуры, его пример и печатает. Из неё растёт всё, что делают с роутером снаружи: и проверка «есть ли такая процедура», и описание API. Ту же таблицу вы соберёте руками в третьей задаче.
Отказ схемы и отказ обработчика полезно различать в журнале. Первый значит «клиент прислал не то» и чинится на клиенте; второй значит «данных нет или нельзя» и чинится в предметной области.
tRPC про Nest ничего не знает и знать не должен. Мост между ними один: fastifyTRPCPlugin регистрируется на том самом экземпляре Fastify, который держит адаптер платформы. Приложение при этом остаётся обычным приложением Nest — с модулями, контроллерами и контейнером.
import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { NotesService } from './notes.service.js'; // Контекст объявляется до create: его тип получают все процедуры этого роутера export type Ctx = { user: string | null }; const t = initTRPC.context<Ctx>().create(); // Роутер собирается из сервиса, который выдал контейнер Nest export function makeRouter(notes: NotesService) { return t.router({ notes: t.router({ byId: t.procedure.input(z.object({ id: z.number().int().positive() })).query(({ input }) => { const note = notes.byId(input.id); if (!note) throw new TRPCError({ code: 'NOT_FOUND', message: `заметки ${input.id} нет` }); return note; }), create: t.procedure .input(z.object({ title: z.string().min(1) })) .mutation(({ input, ctx }) => notes.create(input.title, ctx.user)), mine: t.procedure.query(({ ctx }) => notes.mine(ctx.user)), }), }); } export type AppRouter = ReturnType<typeof makeRouter>;
GET /trpc/notes.byId?input={"id":1} → 200 {"result":{"data":{"id":1,"title":"купить хлеб","author":"anya"}}}
GET /trpc/notes.byId?input={"id":99} → 404 NOT_FOUND (404) заметки 99 нет
GET /trpc/notes.byId?input={"id":0} → 400 BAD_REQUEST (400) [ { "origin": "number", "code": "too_small", "minimum": 0, "
POST /trpc/notes.create (x-user: borya) → 200 {"result":{"data":{"id":2,"title":"молоко","author":"borya"}}}
GET /trpc/notes.create?input={"title":"кефир"} → 405 METHOD_NOT_SUPPORTED (405) Unsupported GET-request to mutation procedure at path "notes
POST /trpc/notes.byId → 405 METHOD_NOT_SUPPORTED (405) Unsupported POST-request to query procedure at path "notes.b
GET /trpc/notes.mine → 200 {"result":{"data":[]}}
GET /trpc/notes.mine (x-user: borya) → 200 {"result":{"data":[{"id":2,"title":"молоко","author":"borya"}]}}
GET /trpc/notes.nope?input={} → 404 NOT_FOUND (404) No procedure found on path "notes.nope"
страж Nest увидел /health
GET /health → 200 {"ok":true}
GET /trpc → 404 {"message":"Cannot GET /trpc","error":"Not Found","statusCode":404}Сервис процедуры берут у контейнера: makeRouter(app.get(NotesService)). Это ровно тот способ, которым сервис достаётся и контроллеру, и экземпляр получается один и тот же. Транспорта два, предметная область одна: переписывать сервисы под tRPC не приходится — мутация кладёт заметку туда же, откуда её берёт любой контроллер.
Контекст собирается на каждый запрос функцией createContext: сюда приезжает сам запрос, отсюда уезжает объект, который увидят все процедуры. В примере это заголовок x-user, и видно, что он работает: notes.mine без заголовка вернул пустой список, а с заголовком — заметку, которую этот же заголовок минуту назад подписал.
А теперь главное, ради чего прогон и снят. Страж приложения повешен через useGlobalGuards и печатает адрес каждого запроса, который до него дошёл. За одиннадцать запросов он напечатал одну строку, и та про /health. Плагин Fastify стоит на платформе — раньше, чем начинается конвейер Nest, — поэтому ни стражи, ни трубы, ни перехватчики с фильтрами процедур не касаются. Куда в таком случае класть проверку личности и частоты — тема следующей статьи.
Из того же вывода видны и две разные 404. На notes.nope отвечает tRPC: «No procedure found on path» — в таблице процедур такого имени нет. А на голый /trpc отвечает Nest своим «Cannot GET /trpc»: адрес не подошёл плагину, и запрос достался ловушке, которую Nest вешает на платформу для ненайденных маршрутов. Два транспорта — два источника отказа, и по телу ответа всегда видно, чей он.
Боевой API автора собран пакетом nestjs-trpc — тем самым, чьи декораторы стоят в последнем разделе. В прогонах его нет, и причина не в предпочтениях. В версии 2.13.0 (25.07.2026) его peerDependencies допускают @nestjs/common и @nestjs/core только ^9.3.8 || ^10.0.0 || ^11.0.0, а часть VI идёт на Nest 12. Issue «Support NestJS v12» открыт 06.09.2026 и к середине сентября ответа не получил; сопровождающий у пакета один.
Поэтому здесь — штатный адаптер @trpc/server/adapters/fastify, а декораторы дальше показаны листингом. Это не приговор пакету: автор issue предлагает обход через peerDependencyRules pnpm, и он вполне может работать — в учебнике он не проверялся. Это повод посмотреть, как вообще выбирают такие пакеты, — чем и займётся полка сервера.
Правило, которое стоит унести целиком: механизм фреймворка действует ровно там, куда фреймворк успел встать. Плагин, повешенный на платформу, стоит раньше — и весь конвейер проходит мимо него.
Обещание tRPC звучит так: клиент получает типы сервера без единой сгенерированной строки. Проверить его проще всего компилятором. В прогоне ниже клиент знает о сервере одно — import type; ни описания API, ни сгенерированного файла у него нет.
import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; type Note = { id: number; title: string }; const rows = new Map<number, Note>([[1, { id: 1, title: 'купить хлеб' }]]); let nextId = 2; const t = initTRPC.create(); export const appRouter = t.router({ notes: t.router({ byId: t.procedure.input(z.object({ id: z.number().int().positive() })).query(({ input }) => { const note = rows.get(input.id); if (!note) throw new TRPCError({ code: 'NOT_FOUND', message: `заметки ${input.id} нет` }); return note; }), create: t.procedure.input(z.object({ title: z.string().min(1) })).mutation(({ input }) => { const note: Note = { id: nextId++, title: input.title }; rows.set(note.id, note); return note; }), }), }); // На клиент уедет только эта строка: значение роутера остаётся на сервере export type AppRouter = typeof appRouter;
client.ts(21,22): error TS2322: Type 'string' is not assignable to type 'number'. client.ts(22,12): error TS2345: Argument of type '"byid"' is not assignable to parameter of type '"byId" | "create"'. client.ts(23,7): error TS2322: Type 'string' is not assignable to type 'number'.
Три диагностики — три разные стороны договора. Первая: во входе { id: "1" } строка на месте числа, хотя тип входа нигде не объявлен — он выведен из схемы zod, которая стоит в роутере. Вторая: опечатка в имени процедуры, и компилятор перечисляет имена целиком — "byId" | "create". Он их не угадал, это ключи роутера, прочитанные из его типа. Третья: выход обработчика приравняли к числу, и это тоже поймано — inferRouterOutputs достал тип ответа оттуда же.
Код выхода у прогона — 2: это и есть успех примера. Компилятор нашёл три ошибки там, где ни одной строки договора не написано руками.
Транспорт в прогоне свой — чтобы видеть, откуда берутся типы. Настоящий клиент устроен так же, только путь собирает Proxy, а вид вызова стоит последним шагом:
Значение роутера на клиент не уезжает, и это не аккуратность, а устройство: import type стирается при компиляции, и в бандл попасть нечему. Уехал бы роутер — уехали бы вместе с ним и обработчики, и доступ к базе, и секреты.
Типизированный клиент не защищает сервер. Тип живёт до сборки, а на проводе едет текст, и чужой клиент напишет в нём что угодно. Защищает по-прежнему схема входа.
В apps/api автора процедур двенадцать в двух роутерах, а контроллеров всего два: здоровье и загрузка файла. Написаны процедуры не цепочкой, а декораторами — пакетом nestjs-trpc. Вот выдержка из notes.router.ts, комментарии авторские.
Узлы всё те же. @Router({ alias: "notes" }) — ветка роутера, та самая t.router({ notes: … }). @Query и @Mutation — вид, объявленный сверху, а не последним шагом цепочки; input и output в их опциях — те же две схемы. @Ctx() и @Input() — параметры обработчика, ровно как @Req() и @Body() у контроллера. А @UseMiddlewares(AuthMiddleware) — то самое звено, которого лишился страж этажом выше.
Плата за декораторы одна, и она крупная: генератор. Из декораторов тип роутера не выводится — они кладут метаданные в рантайме, а компилятору нужен тип во время сборки. Поэтому npx nestjs-trpc generate собирает отдельный файл, где все обработчики — заглушки, и уже его export type AppRouter уезжает на клиент. Этот файл вы видели: им кончалась глава 6.
Вот и весь размен. Цепочка отдаёт тип даром, но пишется не по-нестовски: сервис в процедуру приходится передавать руками, как в прогоне выше. Декораторы возвращают привычный Nest — внедрение через конструктор, звенья на методе, — и требуют шага сборки, который надо не забыть выполнить, и пакета, который надо дождаться. Выбор тут не «лучше — хуже», а «что в вашем проекте ломается громче».
Пакет-посредник между двумя библиотеками живёт на пересечении их версий. Это всегда самое узкое место зависимости — и первое, что стоит посмотреть, прежде чем строить на ней API.
- Настоящая процедура собирается цепочкой: каждый шаг возвращает нового строителя, и поэтому общая заготовка с уже навешанными звеньями не портится от того, что от неё отошла процедура.
- Цепочку закрывает вид —
.query(),.mutation()или.subscription(). От вида зависит метод запроса и место входа: у запроса он в адресе, у мутации в теле. - Отказ схемы ставит сам tRPC, до обработчика, и кладёт в
causeошибку разбора: родную для схемы со своимparseи свою собственную для схемы, живущей одним~standard;TRPCErrorиз обработчика приходит безcause. Схема при этом не только проверяет, но и приводит: дальше едет разобранное значение. - В Nest процедуры и контроллеры делят контейнер, но не конвейер: плагин стоит на платформе, и стражи, трубы, перехватчики и фильтры Nest до процедур не доезжают.
- Клиенту достаётся один
import type: ни описания API, ни кодогенерации. Компилятор ловит по нему и неверный вход, и опечатку в имени процедуры, и неверно использованный ответ. - Декораторы
nestjs-trpcдают привычный Nest, но требуют генератора: тип роутера из них не выводится, и его собирают отдельным файлом с обработчиками-заглушками.
1/4Почему шаг input в цепочке возвращает нового строителя, а не дописывает схему в прежнего?
2/4Процедура объявлена через .mutation(), а клиент зовёт её методом GET. Что ответит сервер?
3/4В приложении Nest плагином подключён tRPC, а через useGlobalGuards повешен страж. Какие запросы он увидит?
4/4Что должно уехать на клиент, чтобы вызов api.notes.byId.query({ id: 1 }) проверял компилятор?
Схема выхода: шаг output
У строителя есть только схема входа, а в настоящей цепочке между input и query встаёт ещё и output: выход тоже проходит через схему, и наружу едет разобранное значение. Добавьте этот шаг. После правки byId должен отдавать только id и title, а authorEmail наружу уезжать не должен. Подложку не трогайте: parse и object в ней уже есть.
У строителя появляется второй параметр типа — тип выхода, — и он путешествует по цепочке вместе с входом. Шаг output меняет его, шаг input оставляет как есть.
Дальше в роутере между input и query добавляется .output(object({ id: int, title: text })). Лишнее поле пропадает не потому, что его вычеркнули, а потому, что object собирает новый объект из объявленных полей — как и на входе.
Зачем это нужно на самом деле: схема выхода не даёт утечь тому, чего клиенту знать не положено. Поле в таблице добавили, обработчик вернул строку целиком — и адрес автора уехал наружу молча. Со схемой выхода не уедет. В tRPC шаг необязателен: без .output() тип ответа выводится из обработчика — в прогоне с компилятором именно так и вышло. А вот в сгенерированном файле nestjs-trpc обработчиков нет вовсе, одни заглушки, и там процедура без .output() отдаёт клиенту any.
Контекст до обработчика
Контекст — это то, что известно про вызывающего: в настоящем tRPC его собирает createContext на каждый запрос. Здесь он уже приезжает в invoke, но до обработчика не доходит, и notes.mine отдаёт всем одно и то же. Проведите контекст до обработчика так же, как это делает tRPC: обработчик получает объект с полями input и ctx. После правки каждый должен увидеть только свои заметки, а гость — ни одной.
Меняется подпись обработчика — и в типе процедуры, и в фабрике, и в вызове.
Гость получает пустой список, а не отказ, — и это не поблажка. «Свои заметки» для гостя — честный пустой ответ; отказывать надо там, где без личности ответить нечем. В боевом API автора это разведено ровно так: list открыт гостю, mine стоит под звеном проверки токена.
И ещё одно, ради чего задача написана: обработчик теперь получает один объект, а не два аргумента. Добавить в этот объект третье поле — новость дня, добавить третий аргумент — правка всех процедур сразу.
Таблица процедур: путь и вид
Роутер — обычный объект, и по нему можно пройтись. Из такого обхода вырастает всё, что делают с роутером снаружи: проверка «есть ли такое имя», список процедур, описание API. Сейчас flatten видит только листья верхнего уровня. Сделайте обход рекурсивным: в таблицу должны попасть все процедуры роутера, на любой глубине, с полным путём через точку и видом.
Развилка та же, что в клиенте главы 6: лист или ветка. Лист узнаётся по run, всё остальное — ветка, в которую надо спуститься, дописав шаг к пути.
Именно такую таблицу держит у себя настоящий tRPC: appRouter._def.procedures — плоский объект «полное имя → процедура», собранный при сборке роутера один раз, а не на каждый запрос. Поиск по имени превращается в обращение по ключу, а не в обход дерева.