Процедура вместо маршрута: @Router, @Query, @Mutation

транспорт≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Роутер вы уже написали: процедура — это схема входа рядом с обработчиком, сам роутер — обычный объект, а клиент вырастает из его типа. Настоящий tRPC устроен так же, и в этой главе он наконец появляется на экране — вместе с боевым API автора, где процедур двенадцать, а контроллеров всего два. От миниатюры настоящий пакет отличается в трёх местах: процедура собирается цепочкой, у неё есть вид — запрос или мутация, — и от вида зависит метод запроса. А потом процедуры въедут внутрь Nest, и выяснится, что контейнер у них с контроллерами общий, а конвейер — нет. Выяснится не из документации: из вывода прогона.

Разделы темы:·

В миниатюре процедура собиралась одним вызовом: procedure(схема, обработчик). В настоящем tRPC то же самое пишется цепочкой: t.procedure.input(схема).query(обработчик). Длиннее, и поначалу кажется, что это вкусовщина. Не вкусовщина: цепочка решает задачу, которой у миниатюры не было.

Задача такая. У половины процедур настройки общие: одни открыты гостю, другие требуют токена, третьи ещё и считают частоту вызовов. С тремя аргументами это пишется в каждой процедуре заново. С цепочкой — один раз: const protectedProcedure = t.procedure.use(проверкаТокена), и дальше от этой заготовки отходят десятки процедур. Чтобы так было можно, шаг обязан не править заготовку, а возвращать новую — иначе первая же процедура испортила бы её всем остальным.

Соберём такой строитель сами, поверх схем и call из типизированного клиента главы 6.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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 не повторяется, не кешируется, ссылкой не становится. Объявив процедуру запросом, вы всё это разрешаете; объявив мутацией — запрещаете. Поэтому вид и стоит в объявлении, а не выводится из того, что обработчик делает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе половины сошлись: 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) — вызвать процедуру прямо из кода, минуя и сеть, и разбор запроса.

прогон node 26.5.0 · 23 сентября 2026
Процедуры настоящего tRPC, вызванные из кода: без сети и без сервера
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;
node main.ts

Два отказа здесь устроены по-разному, и разница важнее их кодов. 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 — с модулями, контроллерами и контейнером.

прогон node 26.5.0 · 23 сентября 2026
Процедуры tRPC и контроллер 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>;
nest start

Сервис процедуры берут у контейнера: 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, ни сгенерированного файла у него нет.

прогон node 26.5.0 · 23 сентября 2026
Компилятор проверяет вызовы клиента по типу роутера; своего описания 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;
tsc --noEmit

Три диагностики — три разные стороны договора. Первая: во входе { id: "1" } строка на месте числа, хотя тип входа нигде не объявлен — он выведен из схемы zod, которая стоит в роутере. Вторая: опечатка в имени процедуры, и компилятор перечисляет имена целиком — "byId" | "create". Он их не угадал, это ключи роутера, прочитанные из его типа. Третья: выход обработчика приравняли к числу, и это тоже поймано — inferRouterOutputs достал тип ответа оттуда же.

Код выхода у прогона — 2: это и есть успех примера. Компилятор нашёл три ошибки там, где ни одной строки договора не написано руками.

Транспорт в прогоне свой — чтобы видеть, откуда берутся типы. Настоящий клиент устроен так же, только путь собирает Proxy, а вид вызова стоит последним шагом:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Значение роутера на клиент не уезжает, и это не аккуратность, а устройство: import type стирается при компиляции, и в бандл попасть нечему. Уехал бы роутер — уехали бы вместе с ним и обработчики, и доступ к базе, и секреты.

Типизированный клиент не защищает сервер. Тип живёт до сборки, а на проводе едет текст, и чужой клиент напишет в нём что угодно. Защищает по-прежнему схема входа.

В apps/api автора процедур двенадцать в двух роутерах, а контроллеров всего два: здоровье и загрузка файла. Написаны процедуры не цепочкой, а декораторами — пакетом nestjs-trpc. Вот выдержка из notes.router.ts, комментарии авторские.

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 в ней уже есть.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У строителя появляется второй параметр типа — тип выхода, — и он путешествует по цепочке вместе с входом. Шаг output меняет его, шаг input оставляет как есть.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Дальше в роутере между input и query добавляется .output(object({ id: int, title: text })). Лишнее поле пропадает не потому, что его вычеркнули, а потому, что object собирает новый объект из объявленных полей — как и на входе.

Зачем это нужно на самом деле: схема выхода не даёт утечь тому, чего клиенту знать не положено. Поле в таблице добавили, обработчик вернул строку целиком — и адрес автора уехал наружу молча. Со схемой выхода не уедет. В tRPC шаг необязателен: без .output() тип ответа выводится из обработчика — в прогоне с компилятором именно так и вышло. А вот в сгенерированном файле nestjs-trpc обработчиков нет вовсе, одни заглушки, и там процедура без .output() отдаёт клиенту any.

Контекст до обработчика

Контекст — это то, что известно про вызывающего: в настоящем tRPC его собирает createContext на каждый запрос. Здесь он уже приезжает в invoke, но до обработчика не доходит, и notes.mine отдаёт всем одно и то же. Проведите контекст до обработчика так же, как это делает tRPC: обработчик получает объект с полями input и ctx. После правки каждый должен увидеть только свои заметки, а гость — ни одной.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Меняется подпись обработчика — и в типе процедуры, и в фабрике, и в вызове.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Гость получает пустой список, а не отказ, — и это не поблажка. «Свои заметки» для гостя — честный пустой ответ; отказывать надо там, где без личности ответить нечем. В боевом API автора это разведено ровно так: list открыт гостю, mine стоит под звеном проверки токена.

И ещё одно, ради чего задача написана: обработчик теперь получает один объект, а не два аргумента. Добавить в этот объект третье поле — новость дня, добавить третий аргумент — правка всех процедур сразу.

Таблица процедур: путь и вид

Роутер — обычный объект, и по нему можно пройтись. Из такого обхода вырастает всё, что делают с роутером снаружи: проверка «есть ли такое имя», список процедур, описание API. Сейчас flatten видит только листья верхнего уровня. Сделайте обход рекурсивным: в таблицу должны попасть все процедуры роутера, на любой глубине, с полным путём через точку и видом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Развилка та же, что в клиенте главы 6: лист или ветка. Лист узнаётся по run, всё остальное — ветка, в которую надо спуститься, дописав шаг к пути.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Именно такую таблицу держит у себя настоящий tRPC: appRouter._def.procedures — плоский объект «полное имя → процедура», собранный при сборке роутера один раз, а не на каждый запрос. Поиск по имени превращается в обращение по ключу, а не в обход дерева.