tRPC v9 路由(Router)定义与输入校验实战指南:从 Procedure 到 Method Chaining
本指南以 tRPC v9 官方文档《Define Router》(仓库内归档于
www/versioned_docs/version-9.x/server/router.md)为主体,系统讲解 v9 时代通过trpc.router<Context>()链式定义路由(Router)、把查询/变更/订阅建模为 Procedure(端点)的完整姿势,并重点剖析 Zod、Yup、Superstruct 等输入校验方案。同时结合本仓库当前源码(packages/server)揭示其底层实现机制(输入解析管线、保留字约束、扁平化路径索引等),帮助你既会写、也懂为什么这样写。
先理解三个核心概念:Procedure、Query/Mutation、Subscription
在动手写路由之前,v9 文档用一段 :::info 明确交代了三个贯穿始终的认知:
- Procedure 可以被视为 REST 端点的等价物(equivalent of a REST-endpoint)。一个 Procedure 就是对外暴露的一个可调用单元,携带输入(
input)并返回输出(output),与 REST 中一个POST /hello之类的端点一一对应。 - 查询(query)与变更(mutation)在内部没有任何区别,差异纯粹是语义上的(semantics)。它们底层共用同一套调用、解析与错误处理机制,选择哪一种只影响客户端语义(如 React Query 中查询可缓存、变更不会重复触发)以及 HTTP 侧的方法映射。
- 定义路由的方式对 query、mutation、subscription 三者完全一致,唯一的例外是 subscription 需要返回一个
Subscription实例。也就是说,三者共享同样的链式定义语法,只是解析函数(resolve)的返回值形态不同。
从当前仓库源码可以看到,v9 文档描述的这套“链式+语义类型”模型的沉淀物仍然存在:procedure.ts 中定义了 LegacyObservableSubscriptionProcedure(标注 @deprecated)与 SubscriptionProcedure,并通过联合类型 AnySubscriptionProcedure 将它们统一归入 AnyProcedure;在 router.ts 中,DecorateProcedure 会按 TProcedure['_def']['type'] extends 'subscription' 分支决定调用方的返回类型是 Observable<...> 还是普通输出。这印证了文档所说:三种 Procedure 共享类型与定义机制,subscription 仅因返回值形态而不同。
定义第一个 Router:无输入的最简 Procedure
v9 最典型的路由定义方式,是通过 trpc.router<Context>() 返回的 builder 上链式调用 .query()、.mutation()、.subscription()。先看文档中没有输入的最简示例:
import * as trpc from '@trpc/server';
// [...]
export const appRouter = trpc
.router<Context>()
// Create procedure at path 'hello'
.query('hello', {
resolve({ ctx }) {
return {
greeting: `hello world`,
};
},
});
要点拆解:
trpc.router<Context>():Context是你的上下文类型泛型参数(类型参数是可选的;不传时相关上下文类型为object)。它贯穿整条链,决定后续每个 procedure 的resolve({ ctx })中ctx的类型。.query('hello', { resolve }):第一个参数'hello'是过程路径(procedure path),它是客户端调用时的寻址键,例如 tRPC 客户端通过trpc.hello即可访问。第二个参数是过程定义对象,核心字段为resolve解析函数。resolve({ ctx }):解析函数接收包含ctx的解构参数对象,返回的数据即为该端点的输出,会被服务端序列化后返回给客户端。本示例中无论谁调用都固定返回{ greeting: 'hello world' }。
定义完成后,通常将类型导出,供客户端做端到端类型安全引用:
export type AppRouter = typeof appRouter;
该类型导出的做法在版本化文档 infer-types 中有更系统的说明。值得留意:router<Context>() 这种通过链式调用与 resolve({ ctx }) 回调组织的 API 是 v9(以及 v10)时代 的写法;仓库当前主线版本的 API 已演进为基于 initTRPC 创建实例后以 t.router({...}) 的对象式定义。阅读归档在 www/versioned_docs/version-9.x/ 下的这份文档时,请将其视为该历史版本的官方用法快照。
输入校验:为什么必须有、以及有哪些姿势
文档用一个独立小节强调输入校验:tRPC 开箱即用地支持 yup / superstruct / zod / myzod / 自定义校验器(custom validators)等,并有对应的测试套件验证(文档指向的测试套件在仓库中的落点即 validators.test.ts)。
为什么每个带输入的 procedure 都应声明 input?核心原因是跨网络的数据都经过 JSON 序列化,类型系统无法保证运行时数据的形态。传入的 input 会先经过你声明的校验器解析(parse),失败即抛出校验错误,成功后才把“已验证/已规整”的数据交给 resolve({ input })。因此,input 字段充当“运行时防火墙 + 类型收窄器”的双重角色。
使用 Zod
import * as trpc from '@trpc/server';
import { z } from 'zod';
// [...]
export const appRouter = trpc.router<Context>().query('hello', {
input: z
.object({
text: z.string().nullish(),
})
.nullish(),
resolve({ input }) {
return {
greeting: `hello ${input?.text ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
z.string().nullish():允许text为string | null | undefined。- 整个对象 schema 再套
.nullish():表示客户端甚至可以完全不传input。 - 因此
resolve中input的类型被推导为{ text?: string | null } | null | undefined,代码里用input?.text ?? 'world'做空值兜底是安全的。
使用 Yup
import * as trpc from '@trpc/server';
import * as yup from 'yup';
// [...]
export const appRouter = trpc.router<Context>().query('hello', {
input: yup.object({
text: yup.string().required(),
}),
resolve({ input }) {
return {
greeting: `hello ${input?.text ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
与 Zod 示例的差异在于:Yup 版本里 text 是 yup.string().required(),即必填。resolve 中的 input.text 在类型层面已非空——这正是“校验器同时完成运行时校验与类型收窄”的直观体现。
使用 Superstruct
import * as trpc from '@trpc/server';
import * as t from 'superstruct';
// [...]
export const appRouter = trpc.router<Context>().query('hello', {
input: t.object({
/**
* Also supports inline doc strings when referencing the type.
*/
text: t.defaulted(t.string(), 'world'),
}),
resolve({ input }) {
return {
greeting: `hello ${input.text}`,
};
},
});
export type AppRouter = typeof appRouter;
Superstruct 用 t.defaulted(t.string(), 'world') 为字段提供默认值——缺省输入会被规整为 'world',因此 resolve 里可以直接写 input.text(类型非空),无需空值兜底。文档特意指出 Superstruct 结构体上的注释在被引用时会成为内联文档字符串(inline doc strings),便于 IDE 悬停提示与类型文档生成。
各种校验器是如何被统一识别的:源码级解析管线
不同校验库的 API 差异很大(zod 是 .parse/.parseAsync,yup 是 .validateSync,superstruct 是 .create,myzod/自定义则是普通函数)。tRPC 之所以能“开箱即用”,是因为 parser.ts 中的 getParseFn 充当了鸭子类型分诊器:按能力特征逐个探测解析器并包装成统一的内层解析函数:
- 普通函数且含
.assert:按 arktype 处理,调用parser.assert.bind(parser)(避免直接函数调用返回联合类型而抛不出错); - 普通函数且非 Standard Schema:视为 myzod / 自定义校验器,直接作为 parse 函数调用;
- 有
.parseAsync:按 zod 处理(优先异步解析); - 有
.parse:按 zod 或旧版 valibot 处理; - 有
.validateSync:按 yup 处理; - 有
.create:按 superstruct 处理; - 有
~standard:走 Standard Schema 规范校验,失败时抛出StandardSchemaV1Error; - 均不匹配则抛出
'Could not find a validator fn'。
从源码结构看,这一探测顺序还决定了“自定义校验器”的推荐形态——一个接收 unknown、返回 TInput | Promise<TInput> 的函数即可直接充当 input 校验器,对应类型为 ParserCustomValidatorEsque。理解了这条管线,你就能预判一个第三方校验库能否被 tRPC 直接接受:只要它具备上表任一能力形态即可。
Method chaining:链式追加多个端点
文档特别强调:要添加多个端点,必须链式调用(chain the calls)。.router<Context>() 返回的 builder 是不可变地延续类型信息的:每次 .query() / .mutation() 都会在返回的新类型上叠加刚声明的过程路径,因此只有写在同一串链上,路径才会被累积注册。
import * as trpc from '@trpc/server';
// [...]
export const appRouter = trpc
.router<Context>()
.query('hello', {
resolve() {
return {
text: `hello world`,
};
},
})
.query('bye', {
resolve() {
return {
text: `goodbye`,
};
},
});
export type AppRouter = typeof appRouter;
这份代码定义了两个无输入端点:hello 与 bye,客户端分别通过 trpc.hello、trpc.bye 调用,输入为空时 resolve() 连 input 形参都不必解构。
底层是如何“累积”这些过程的:从链式 builder 到扁平索引
当前仓库主干实现中,过程集合被最终收敛到统一的扁平结构中:router.ts 里的 step 函数会递归遍历路由树,把每个叶子过程以点号拼接的完整路径(dotted path) 为键写入 procedures 扁平表(procedures[newPath] = item),同时维护嵌套结构 record 用于生成类型与调用代理;getProcedureAtPath 则依据路径在表中查过程。这种“树状声明、扁平索引”的设计解释了为什么“链式多个端点”与“对象式嵌套路由”能统一寻址:无论声明形态如何,最终都落到 'hello'、'posts.list' 之类的点号路径上。
值得留意的工程细节是保留字校验:源码 router.ts(reservedWords 定义于 L210-L221 附近)禁止过程或路由命名为 then、call、apply,原因在于路由器对外暴露的可调用代理是 Promise/函数语义对象,若允许这些名字会产生 .then、.call()、.apply() 冲突,破坏代理与类型推断;一旦使用会直接抛出 'Reserved words used in router() call: ...'。另外,当多条链或合并路由出现重复的叶子路径时,step 会抛出 Duplicate key: ${newPath},从源头拦截歧义端点。这些机制在 v9 文档示例的小型路由中不会触发,但当你把路由拆分成多文件再用 mergeRouters(详见 merging-routers)合并、或按需懒加载(源码提供 lazy() 包装,router.ts 中 Lazy / createLazyLoader 实现)时,会直接关系到能否安全组合,值得提前了解。
一张图式的速查小结
| 声明内容 | 关键写法 | 说明 |
|---|---|---|
| 定义路由起点 | trpc.router<Context>() |
传入上下文类型泛型,开启链式定义 |
| 无输入查询 | .query('hello', { resolve() { ... } }) |
resolve 固定返回输出对象 |
| Zod 校验 | input: z.object({...}).nullish() |
支持 .nullish() / 类型收窄 |
| Yup 校验 | input: yup.object({ text: yup.string().required() }) |
通过 .validateSync 接入解析管线 |
| Superstruct 校验 | input: t.object({ text: t.defaulted(t.string(), 'world') }) |
缺省字段自动规整为默认值 |
| 自定义校验器 | 一个 (input: unknown) => TInput 函数 |
由鸭子类型探测直接采用 |
| 多个端点 | 在同一链上反复 .query() / .mutation() |
端点以点号路径扁平索引注册 |
| 类型导出 | export type AppRouter = typeof appRouter |
供客户端端到端类型引用 |
结语
tRPC v9 的 Router 定义模型可以概括为一句话:把端点建模为过程(Procedure),用一条链把路径与解析函数累积起来,再让每个过程的输入先穿过你选择的校验器,最后在类型完全收敛的前提下把数据交给 resolve。实践上记住三点即可写出健壮的服务端:第一,带输入的端点务必声明 input 校验器,把运行时数据挡在类型系统之外;第二,无输入时 resolve 可直接省略 input 解构;第三,多端点要链式书写,且端点命名避开 then / call / apply 等保留字。若想深挖底层,可从 parser.ts 的 getParseFn 看校验器接入原理、从 router.ts 的过程扁平化与保留字约束看路由实现的工程细节,再配合 validators.test.ts 了解官方对各校验库的兼容性保障。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00