Клиент, выведенный из типа роутера: tRPC изнутри

конвейер≈ 19 минут

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

Каркас главы умеет всё, что нужно серверу: страж и труба стоят на входе, схема проверяет тело и заодно даёт компилятору тип, а перехватчик и фильтр закрывают всё, что после обработчика. А клиент этого API до сих пор пишется руками: интерфейс ответа отдельным описанием, as поверх JSON.parse — и никакой связи с сервером, кроме вашей памяти. Это вторая копия одного договора, и расходится она молча. В этой статье сервер перестанет быть набором маршрутов и станет обычным значением — объектом процедур, — а из типа этого значения вырастет клиент, которого никто не писал: api.notes.byId({ id: 1 }) с подсказками, переименованием и отказом компилятора на неверный вход. Весь он — три строки типа и десять строк рантайма, и в конце выяснится, что вы собрали tRPC — до всякого знакомства с tRPC.

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

У вашего API уже есть обработчик — метод, который возвращает заметку, и её тип на сервере записан: { id: number; title: string; body: string }. Тип точный, он на месте, и описывает он ровно то, что уедет клиенту.

А на клиенте его нет. По проводу приезжает текст, JSON.parse отдаёт any, и чтобы дальше писать код с подсказками, тип приходится объявить заново — интерфейсом рядом с вызовом. Договор оказывается записан дважды: один раз выведен из обработчика, второй раз набран от руки. Две записи одного правила расходятся всегда; вопрос только в том, скажет ли об этом кто-нибудь.

Не скажет. as — не преобразование, а обещание, и опаснее всего приведения, которые проходят молча. Прогноз: что напечатают три строки, если на сервере поле зовётся body, а в интерфейсе клиента — text?

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

Ни одной диагностики. JSON.parse вернул any, as NoteView компилятор принял как данность — и с этого места он твёрдо знает, что у note есть text. Знание кончилось в рантайме: сначала undefined, потом TypeError на его длине.

Обычное лечение — «не забывать править обе копии» — не лечение: забывают. Настоящее лечение одно: копий не должно быть. Тип клиента не должен совпадать с типом сервера — он должен им быть.

Полезно проверить свой клиент прямо сейчас: если ответ сервера описан интерфейсом, который никто не выводит из обработчика, значит, он уже где-то разошёлся. Просто вы об этом пока не знаете.

Чтобы тип сервера попал к клиенту, его надо откуда-то взять. У каркаса главы брать неоткуда: сборка складывает маршруты в таблицу на слабых ссылках, а в таблице лежат строки и функции — способ, путь, обработчик. Типа ответа там нет и быть не может: таблица — значение, а тип живёт только в компиляторе и до рантайма не доезжает.

Поэтому форма описания меняется. Процедура — это пара: схема входа и обработчик, лежащие в одном объекте. Роутер — обычный объект, в ветках которого другие объекты, а в листьях процедуры. Ни декораторов, ни реестра на стороне: всё, что есть у сервера, написано обычным литералом, и typeof видит это целиком.

Адрес меняется тоже. Маршрутизатор искал обработчик по способу и образцу пути — с сегментами, параметрами и 405 на неверный способ. Процедуру находят по имени: notes.byId — это два шага по объекту, и никакого разбора пути. Прогноз: чем ответят три последних вызова?

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

Первые две строки — обычные вызовы: вход прошёл через схему, и обработчик получил уже разобранное значение. Дальше три отказа, и все три от разных мест: int не принял строку, object собрал обе причины сразу, а call не нашёл процедуры с таким именем — по объекту он прошёл, а на последнем шаге получил undefined.

Схема здесь та же самая, из которой в прошлой статье выводился тип: object({ id: int }) — значение, дающее и проверку, и тип. Новое только одно: она переехала внутрь процедуры и стала частью описания сервера, а не настройкой трубы на отдельном маршруте.

У процедуры нет ни способа запроса, ни пути, ни кода ответа. Это не упрощение ради главы: настоящий tRPC устроен так же, и весь его транспорт — один адрес, к которому приписано имя процедуры.

Роутер — значение, и у него есть точный тип: typeof router. В этом типе уже записано всё, что нужно клиенту, — осталось достать. Достаёт infer: условный тип описывает образец, а infer помечает в нём кусок, который надо вернуть.

Образец у нас уже есть — Procedure<I, O>. Приложить к нему процедуру и поймать оба имени разом: P extends Procedure<infer I, infer O> ? I : never — это вход, и точно так же с O — выход. Обратите внимание, что ловятся оба имени, хотя нужно каждый раз одно. Так и надо, и почему — покажет последняя строка примера.

Прогноз: две строки вывода — и, главное, сработает ли @ts-expect-error на заметке со статусом archived, если этого ограничения никто не писал в типе руками?

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

CreateInput не написан нигде. Он собрался сам: object вернула схему объекта, oneOf("draft", "published") — схему объединения двух литералов, а Input прочитал это из процедуры. Поэтому archived и не прошёл: ограничение записано в схеме, из схемы попало в тип, из типа — в диагностику. Директива не пожаловалась на саму себя — значит, ошибка действительно была.

Сработала директива и на последней строке, и вот там ошибка неожиданная: «Не удается найти имя "I"». Имя, пойманное через infer, живёт только в ветке «да» — об этом ограничении сказано в статье про infer вторым пунктом, — а сам образец ещё не ветка. Пока компилятор сопоставляет форму, I не существует: он именно этим сопоставлением и вычисляется.

Отсюда и правило: связь входа с обработчиком внутри одного образца не выражается. Оба имени ловятся сразу и отдаются именованному псевдониму — Procedure<infer I, infer O>, — а он уже сам расставляет их по местам, в схему и в обработчика. Ненужный I в Output — плата ровно за это: unknown на его месте образец уже не сведёт.

Соблазн — ловить в образце только нужное имя, а на место второго писать unknown. В Input это сходит с рук: P extends Procedure<infer I, unknown> ? I : never даёт тот же вход, потому что unknown стоит на месте результата, а результаты сравниваются в обычную сторону. В Output тот же приём кончается тихо и плохо: P extends Procedure<unknown, infer O> ? O : never не сойдётся, и Output вернёт never. У Procedure<unknown, O> обработчик объявлен как (input: unknown) => O, а наш принимает { id: number }: функцию, которая требует объект, нельзя подставить туда, где её позовут с чем угодно — параметры сравниваются наоборот.

Диагностики на самом Output при этом нет — он честно вернул тип из ветки «нет». Ошибка всплывёт этажом ниже, на присваивании, и будет выглядеть загадочно: «Тип … не может быть назначен для типа "never"». Правило на будущее: если условный тип вдруг отдал never, ищите не опечатку в ветке «да», а несошедшийся образец.

Два правила про infer стоят рядом и путаются. Писать его можно только в предложении extends — иначе TS1338. Пользоваться пойманным именем можно только в ветке «да» — иначе «не удается найти имя». Второе и ловит тех, кто пробует связать вход с выходом прямо в образце.

Одну процедуру читать научились — теперь то же самое по всему роутеру. Проход по ключам, [K in keyof R], и на каждом ключе развилка: лист — это процедура, и она превращается в функцию (input: I) => Promise<O>; всё остальное — ветка, и на ней тот же Client ещё раз, только глубже. Три строки, которые описывают клиента любой глубины и любого состава.

В рантайме клиента тоже никто не пишет — его собирает Proxy. Ловушку get вы уже видели: так устроен реестр нативных модулей — объект без единого своего свойства, который отвечает на вопрос по имени. Здесь она копит путь: api.notes — один шаг, api.notes.byId — два, и ни одного вызова ещё не было. Новая ловушка — apply: её зовут, когда прокси вызвали как функцию. Ради неё целью прокси и объявлена пустая функция: на обычном объекте apply не сработает вовсе.

Надеть на этот прокси выведенный тип нельзя — компилятор внутрь ловушек не заглядывает. Поэтому во всём клиенте есть ровно одно место, где тип не выведен, а объявлен: as unknown as Client<R>. Это та же ложь, что в первом разделе, но сказанная один раз в одной строке — и после неё каждый вызов наверху проверяется по-настоящему.

Прогноз: три строки. Что ответит await api.notes.byId(7), если компилятор этот вызов уже отверг?

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

Первые два вызова прошли насквозь: api.notes.byId — это два срабатывания get, затем apply, затем обычный call по имени notes.byId. Аргумент типизирован, ответ типизирован, подсказки работают — и ничего из этого не написано: всё вычислено из typeof router.

Третий вызов отвергли дважды, и отказы независимы. Компилятор отказал на месте: byId объявлен функцией от объекта, 7 под него не подходит. Валидатор отказал в рантайме: Proxy не проверяет ничего, он только собирает путь, а вход по-прежнему разбирает схема на сервере. Это и есть главное, что стоит унести из статьи: одно — удобство, другое — защита.

Подсказки в редакторе здесь не побочный эффект, а сам результат. Переименуйте процедуру на сервере — и сборка клиента станет красной; это ровно то место, где ошибку ещё дёшево чинить.

У типизированного клиента есть свойство, которое легко принять за защиту: он не даёт послать неверный вход. Не даёт — своему коду и во время сборки. По проводу тип не едет вовсе: там строка, а на той стороне JSON.parse. Старый бандл в браузере читателя, скрипт на питоне, curl из терминала — каждый из них пришлёт что угодно, и ни одна строка вашего Client об этом не узнает.

Отсюда правило, которое не отменяется никаким выводом типов: схема остаётся на сервере, и разбор входа никуда не девается. Тип клиента — удобство: подсказки, переименование, красная сборка вместо красного продакшена. Проверка — это схема, и она выполняется там, куда чужой клиент дотянуться не может.

Зато у роутера-значения есть вторая польза, ради которой его и делают объектом: его можно обойти в рантайме и переписать. Имена процедур достаются даром — обычным обходом ключей. Прогноз: три строки; что окажется во второй?

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

Список имён собрался сам, и в нём ровно то, что есть на сервере. А вторая строка — {}: что процедура ждёт на входе, по таблице не прочитать. Схема — замыкание, внутри которого лежит функция check, и рассказать о себе она не умеет: ни списка полей, ни их типов. Всё это есть в типе — то есть только в компиляторе.

Здесь и проходит граница между вашим валидатором и zod. У zod-схемы форма хранится значением, поэтому её удаётся превратить в JSON Schema, а JSON Schema — в описание API. В боевом API автора это записано прямо в комментарии к выходным схемам: кодогенератор строит тип роутера из схем входа и выхода, и без выходной схемы клиенты получат any. Откуда вырастает описание API и чем оно отличается от типизированного клиента — отдельная статья.

Третья строка — про провод: то же имя процедуры, тот же роутер, но вход приехал текстом, и id в нём строка. Отказала схема. Клиента на этом пути не было вовсе — и именно так выглядит любой запрос, который пришёл не из вашего кода.

Привычка, которая окупается: про каждую проверку спрашивать, на какой она стороне. Всё, что написано в типах, остаётся в вашей сборке. Всё, что должно пережить чужого клиента, пишется значением и выполняется на сервере.

Всё, что вы собрали, есть в tRPC, и почти один в один.

МиниатюраtRPC 11Что стоит знать
procedure(schema, run)t.procedure.input(schema).query(run)у настоящей процедуры есть ещё и вид: query, mutation, subscription
const router = { notes: { … } }t.router({ notes: t.router({ … }) })такой же объект, только собранный фабрикой: ей нужны контекст и трансформер
type Input<P>, type Output<P>inferRouterInputs, inferRouterOutputsтот же обход, но сразу по всему роутеру; сам вход и выход tRPC не ловит образцом, а держит в фантомном поле _def['$types'] процедуры
type Client<R>TRPCClient<TRouter>проход по ключам с той же развилкой «лист или ветка»
client(router)createTRPCClient<AppRouter>({ links })роутер клиенту не передают: у него есть только тип
call(router, "notes.byId", input)/trpc/notes.byIdимя процедуры — в пути; запрос уходит методом GET с целым входом в параметре input, мутация — методом POST
as unknown as Client<R>createRecursiveProxy<TRPCClient<TRouter>>то же единственное объявление типа на весь клиент

Самая важная строка здесь пятая. Клиенту достаётся тип роутера, а не роутер: createTRPCClient<AppRouter>. Значение сервера в бандл браузера не попадает — иначе туда уехали бы и обработчики, и доступ к базе. Лучше всего это видно в файле, который для этого и генерируется.

Вот выдержка из боевого API автора. Файл собирает кодогенератор nestjs-trpc по декораторам роутеров, и он не исполняется никогда: вся его цель — в последней строке.

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

Каждый обработчик здесь — заглушка: "PLACEHOLDER_DO_NOT_REMOVE" as any. Тел процедур в файле нет и не нужно, потому что клиенту едет одна строка — export type AppRouter = typeof appRouter, а тип от тела не зависит: он вычисляется из схем .input() и .output(). Оттуда же и правило, записанное в комментарии к схемам выхода боевого API: процедура без .output() отдаёт клиенту any.

Клиент tRPC собирает createRecursiveProxy — это @trpc/server 11.18.0, файл src/unstable-core-do-not-import/createProxy.ts. Внутри тот же new Proxy с ловушками get и apply поверх пустой функции. Отличий три, и все три — про вызовы, которых вы не писали.

Первое: get отвечает undefined на любой ключ-символ и на ключ then. Без этого прокси выглядел бы как промис, и Promise.resolve(api.notes) позвал бы then, получил функцию и стал бы ждать её вечно — в комментарии это так и названо: прокси случайно приняли за промис. Второе: вызов valueOf, toString или toJSON последним шагом отвечает отладочной строкой, иначе приведение прокси к примитиву ушло бы в рекурсию. Третье живёт уже не в этом файле, а в @trpc/client (createTRPCClientProxy), и знать о нём всё равно надо: последний шаг пути там — не имя процедуры, а вид вызова, и его снимают с конца, так что api.notes.byId.query({ id: 1 }) уходит как notes.byId способом query.

Свой клиент вы, скорее всего, писать не будете, и не нужно. Нужно другое: видеть в createTRPCClient<AppRouter> не магию, а два знакомых механизма — обход типа по ключам и Proxy, который собирает путь. Настоящие процедуры поверх Nest — глава про транспорт.

Правило «одна ложь на библиотеку» стоит запомнить отдельно. Приведение типа не грех само по себе — грех, когда оно повторяется в прикладном коде. В tRPC оно написано один раз, внутри, и оплачено тем, что все вызовы выше проверяются честно.

  • Клиент, написанный руками, — вторая копия договора: JSON.parse отдаёт any, as ничего не проверяет, и расхождение всплывает только в рантайме.
  • Процедура — это значение: схема входа и обработчик в одном объекте, роутер — обычный литерал, а имя процедуры приезжает по проводу строкой, без пути и способа запроса.
  • infer достаёт из процедуры вход и выход. Пойманное имя живёт только в ветке «да», поэтому связать I с обработчиком внутри самого образца нельзя — оба имени ловятся сразу.
  • Клиент собирается из двух механизмов: проход по ключам роутера в типах и Proxy с ловушками get и apply в рантайме; объявленный, а не выведенный тип в нём ровно один.
  • Тип по проводу не едет: он даёт подсказки и красную сборку, а защищает по-прежнему схема на сервере — и она же остаётся единственной проверкой для чужого клиента.
  • В tRPC это те же узлы: t.router, createTRPCClient<AppRouter> и сгенерированный файл, где все обработчики — заглушки, потому что клиенту нужен только тип.

1/4Почему клиент, написанный руками, расходится с сервером молча?

2/4Что попадает к клиенту в createTRPCClient<AppRouter>?

3/4Почему не компилируется P extends { input: Schema<infer I>; run: (input: I) => infer O } ? O : never?

4/4Вызов api.notes.byId(7) компилятор отверг. Что будет, если тот же вызов придёт по проводу от чужого клиента?

Запрос и мутация: последний шаг пути

В настоящем tRPC вызов кончается видом: api.notes.byId.query({ id: 1 }) и api.notes.create.mutate({ … }). Сейчас такой вызов не компилируется, а в рантайме уходит в call вместе с лишним шагом. Сделайте, чтобы оба вызова работали: в типе у листа вместо функции появляется объект с query и mutate, а в рантайме последний шаг пути снимается перед вызовом. Подложку и call не трогайте.

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

Развилка та же, только лист превращается не в функцию, а в объект с двумя методами. Вид вызова в миниатюре не объявлен, поэтому доступны оба; в tRPC вид объявляет сама процедура (.query() или .mutation()), и у листа остаётся ровно один метод.

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

В рантайме меняется одна строка: путь до вызова теперь на шаг длиннее, и последний шаг — вид, а не имя.

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

После обеих правок печатаются заметка и созданный черновик. Ровно это делает настоящий клиент: он снимает с конца пути query, mutate или subscribe, превращает их в вид вызова, а остаток пути отправляет как имя процедуры.

Клиенту нужен транспорт, а не роутер

На настоящем клиенте роутера нет: он остался на сервере. Есть транспорт — функция, которая отправляет имя процедуры и вход и ждёт ответа; здесь это send, и он пока не вызывается ни разу. Перепишите client так, чтобы роутер ему был не нужен: аргументом — транспорт, типом — typeof router. Конечная строка должна выглядеть как client<typeof router>(send), а в выводе перед каждым ответом появится строка провода.

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

Роутер уходит из аргументов совсем, а параметр типа теперь называют вслух — выводить его больше не из чего.

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

Теперь видно, за что платят типом: сам typeof router в собранный клиент не попадёт — после компиляции от него не останется ни байта, — а подсказки и проверки останутся все. В tRPC роль send играют звенья (links), а роль typeof router — импорт AppRouter с ключевым словом type.

Ветка, которой не место в клиенте

В роутере завелась служебная ветка internal, и сейчас клиент предлагает её наравне с заметками — вместе с процедурой, которая стирает всё. Уберите internal из типа клиента так, чтобы api.notes остался на месте. Менять роутер нельзя. И ответьте себе на второй вопрос: что после этой правки изменится для того, кто зовёт процедуру не через ваш клиент?

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

У прохода по ключам есть предложение as, которое переименовывает ключ, — а ключ, переименованный в never, из результата просто выпадает.

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

Ответ на второй вопрос — ничего. Вызов api.internal.wipe({}) перестанет компилироваться, но процедура осталась в роутере, call по-прежнему находит её по имени, и curl с телом internal.wipe сотрёт заметки как ни в чём не бывало. Спрятать процедуру от клиента типом нельзя: её либо не кладут в роутер, либо закрывают стражем на сервере.