tRPC Validators 权威指南:用 .input() / .output() 实现端到端类型安全校验
导读
本文将系统讲解 tRPC(当前仓库对应 v11.16.0 的 skills 元数据)中 输入/输出校验(Validators) 的完整用法:从最基础的 .input() / .output() 声明式校验,到多 .input() 链式合并对象 schema、接入 Zod / Yup / Superstruct / ArkType / Valibot / Effect 等主流校验库,再到"不依赖任何第三方库、纯函数即校验器"的底层原理与常见陷阱。读完本文,你将掌握让同一份 schema 同时承担运行时校验与 TypeScript 类型推断的能力,并理解为何输出校验失败返回 500、而输入校验失败返回 400——这些差异直接来自 packages/server/src/unstable-core-do-not-import/parser.ts 与 middleware.ts 的实现,而非魔法。
本文以仓库中的官方技能文档 packages/server/skills/validators/SKILL.md 及其引用的权威文档 www/docs/server/validators.md 为主体展开,代码示例均可在仓库对应的 packages/tests/server/validators.test.ts 等测试文件中找到可运行佐证。
一、前置 Setup:一分钟搭起带校验的 router
要使用校验器,第一步永远是初始化 tRPC 实例。校验器本身不依赖任何特定环境,直接在 initTRPC.create() 之后即可使用:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/appRouter.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string() }))
.output(z.object({ greeting: z.string() }))
.query(({ input }) => {
return { greeting: `hello ${input.name}` };
}),
});
export type AppRouter = typeof appRouter;
这就是 tRPC 的"最小可用校验单元":.input() 声明客户端必须且只能提交 { name: string },.output() 声明服务端保证返回 { greeting: string }。客户端拿到 AppRouter 类型后,传入非法参数会在编译期被拦截,而运行时传入的非法数据则会在请求到达解析器之前被拒绝。
运行前提:完整可运行的 demo 可参考仓库 examples/minimal(server 端封装
initTRPC、client 端建立类型安全的调用连接)。skills 元数据中声明requires: server-setup,即本文假设你已经完成 server-setup 中initTRPC/router/procedure的基础搭建。
二、核心模式:从声明式 input 到链式合并
2.1 输入校验(Input Validation)——第一道防线
通过给 procedure 定义输入校验器,tRPC 会在每次调用时先检查参数是否正确,不合法立即返回校验错误,解析器(resolver)根本不会执行:
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
userById: publicProcedure.input(z.string()).query(({ input }) => {
return { id: input, name: 'Katt' };
}),
userCreate: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(({ input }) => {
return { id: '1', ...input };
}),
});
注意这里既可以校验标量(z.string()),也可以校验对象。校验失败时,错误码为 BAD_REQUEST(HTTP 400)。这一点可从 createInputMiddleware 的实现确认:解析抛出的任何异常都会被包装为 code: 'BAD_REQUEST' 的 TRPCError。
2.2 输入合并(Input Merging)——为中间件复用公共入参
.input() 可以叠加调用来构建更复杂的类型,特别适合在 middlewares 中为一批 procedure 提取公共输入。官方文档原话是:合并通过把对象属性展开(spread)在一起完成,因此只有对象类型能被链式合并(z.string()、z.number()、数组等非对象类型无法合并)。
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const baseProcedure = t.procedure
.input(z.object({ townName: z.string() }))
.use((opts) => {
console.log(`Request from: ${opts.input.townName}`);
return opts.next();
});
export const appRouter = t.router({
hello: baseProcedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello ${input.name}, from ${input.townName}` };
}),
});
多次 .input() 调用会合并对象属性,最终输入类型是:
{ townName: string; name: string }
此时中间件可以安全读取 opts.input.townName(合并发生在所有中间件与解析器执行之前),而具体 procedure 只需关心自己追加的 name 字段。若两条链上的对象声明了同名属性,后声明的覆盖先声明的。
源码印证:合并逻辑并非只停留在类型层,运行时也有真实实现。在 middleware.ts 的 createInputMiddleware 中,每次 .input() 都会注册一个输入中间件,当存在多个输入解析器时,通过 { ...opts.input, ...parsedInput } 将解析结果逐层展开合并。而 procedureBuilder.ts 的 input() 会把每次调用追加进 inputs 数组并注册对应中间件,从而支持无限次链式输入。
测试佐证:仓库 packages/tests/server/input.test.ts 中 double input validator 用例专门验证了 roomId(公共)+ text(特定)两次输入合并后,happy path 能同时访问两个字段、sad path 会返回包含具体字段错误的 BAD_REQUEST;同文件第 146 行起还验证了两段可选对象链式合并后的类型仍保持可选/必选语义正确。
2.3 输出校验(Output Validation)——防"脏数据"出境
tRPC 本身已经能通过推断返回值做到类型安全,那么 .output() 还有什么价值?官方文档给出两个明确理由:
- 检查来自不受信任来源的数据是否正确(例如数据库、外部 API 返回的数据,类型声明可能说谎);
- 确保你不会向客户端返回超出必要范围的数据(防止过度暴露字段)。
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
hello: publicProcedure
.output(z.object({ greeting: z.string() }))
.query(() => {
return { greeting: 'hello world' };
}),
});
当解析器返回值与 .output() 声明的结构不一致时,客户端会收到校验错误。关键语义:输出校验失败返回的是 INTERNAL_SERVER_ERROR(500),而不是 BAD_REQUEST(400)——因为"服务端生产了非法数据"属于内部错误,不应当责怪客户端。
源码印证:在 createOutputMiddleware 中,输出解析发生在 next() 成功之后;若 parse(result.data) 抛出异常,会被包装为 message 为 'Output validation failed'、code 为 INTERNAL_SERVER_ERROR 的 TRPCError。同时它不会覆盖 result.ok === false 的中间件失败结果——即解析器内部抛错时原样透传,不会误标为输出校验错误。
延伸:对于 subscription(异步迭代器)类型的 procedure,官方文档说明 .output() 同样适用,详见 subscriptions 指南 的 Output validation 章节,仓库完整实操可参考 packages/server/skills/subscriptions/SKILL.md。
2.4 纯函数校验器——零依赖的"无魔法"方案
tRPC 校验体系的根基其实是一个函数。不引入任何第三方库,也可以这样定义校验器:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.input((value): string => {
if (typeof value === 'string') return value;
throw new Error('Input is not a string');
})
.output((value): string => {
if (typeof value === 'string') return value;
throw new Error('Output is not a string');
})
.query(({ input }) => {
return `hello ${input}`;
}),
});
官方文档对此有一句非常提神的点评:we don't recommend making a custom validator unless you have a specific need, but it's important to understand that there's no magic here — it's just typescript!(我们只在确有特殊需求时才建议手写校验器,但关键在于理解这里没有任何魔法,就是 TypeScript 而已)。校验通过时返回的值会直接成为后续解析器拿到的 input(因此它可以同时充当解析器/转换器),失败时抛出的异常会被包装成对应的 tRPC 错误。
三、Standard Schema 与多库支持矩阵
3.1 统一抽象:Standard Schema 协议
tRPC procedure 的输入/输出不仅用来做运行时校验,还被用于推断 TypeScript 类型——优先遵循 Standard Schema 接口(若校验器提供),否则回退到各库的自定义接口。正因如此,凡是符合 Standard Schema 的校验库都能"开箱即用"。
这一点在 parser.ts 中有完整证据:
- 通过鸭子类型识别各类校验器:
~standard字段判定 Standard Schema、_input/_output判定 Zod 系、validateSync判定 Yup、create判定 Superstruct、assert判定 ArkType/scale-ts、纯函数形态判为自定义校验器; getParseFn依据识别结果绑定对应的执行函数:优先parseAsync(支持异步校验,如 Zod v4 与 typeschema),其次是.parse、validateSync、create、assert,若都不匹配则抛出'Could not find a validator fn';inferParser则按"Standard Schema → 带 input/output 的 → 仅带单一类型的"顺序推导in/out两套类型。
值得注意的边界:标准函数型 schema(如 effect 的某些函数形态 schema)会被特别排除在"直接当 parse 函数调用"之外(见 parser.ts 中 isStandardSchema 的检查注释),这正是 "no magic" 之外仍需引擎适配的细节。
3.2 主流校验库速查表
以下是仓库官方维护、且已在 packages/tests/server/validators.test.ts 中拥有真实运行用例的集成方式:
| 校验库 | input 示例 | output 示例 | 备注 |
|---|---|---|---|
| Zod(官方默认推荐) | z.object({ name: z.string() }) |
z.object({ greeting: z.string() }) |
生态强大,同一 schema 可用于代码库多处;本仓库绝大多数测试用它 |
| Yup | yup.object({ name: yup.string().required() }) |
yup.object({ greeting: yup.string().required() }) |
底层走 validateSync |
| Superstruct | object({ name: string() }) |
object({ greeting: string() }) |
底层走 create |
| ArkType | type({ name: 'string' }) |
—— | ArkType schema 不能当函数直接调用(返回联合而非抛错),故 tRPC 特殊处理为调用其 .assert,见 parser.ts |
| Valibot | v.object({ name: v.string() }) |
v.object({ greeting: v.string() }) |
新版本走 schema._types 推断 |
| Effect Schema | Schema.standardSchemaV1(Schema.Struct({ name: Schema.String })) |
同上 | 显式包装为 Standard Schema v1 使用 |
| scale-ts / scale-codec | $.object($.field('name', $.str)) |
$.object($.field('greeting', $.str)) |
底层走 assert |
| Typia | typia.createAssert<IBbsArticle.IStore>() |
typia.createAssert<IBbsArticle>() |
编译期生成断言函数 |
| TypeBox | wrap(Type.Object({ name: Type.String() })) |
同左 | 借助 @typeschema/typebox 桥接 |
以上示例每个库在官方文档 www/docs/server/validators.md 中都有完整可运行代码段;其中 zod v3、zod v4、yup、superstruct、arktype、valibot、runtypes、myzod、scale-codec、effect 等多个库的端到端读写用例都能在 packages/tests/server/validators.test.ts(共 907 行)中找到,例如其中验证了 ctx.client.num.query('123' as any) 会被拒绝并返回 zod 的 invalid_type 明细错误。
关于默认推荐:官方文档明确建议,若你暂无偏好、且想要一个生态强大、能满足未来扩展需求的库,Zod 是默认推荐;ArkType、Valibot、Effect 等则在包体积、语法、纯函数式风格上各有取舍。本文不展开横向评测。
四、常见陷阱(从 SKILL 文档与 issue 中提炼的实战雷区)
技能文档 packages/server/skills/validators/SKILL.md 特别标注了三个带风险等级的误区,逐一展开:
4.1 [MEDIUM] 链式合并非对象输入 → 类型报错
// ❌ 错误:string 与 number 都不是对象,无法展开合并
const proc = publicProcedure.input(z.string()).input(z.number());
// ✅ 正确:多次 input 均使用对象 schema,属性合并
const proc = publicProcedure
.input(z.object({ name: z.string() }))
.input(z.object({ age: z.number() }));
如前所述,多次 .input() 通过属性展开合并对象,非对象 schema(string/number/array 等)无法合并,会在编译期直接产生类型错误。这也与 middleware.ts 的运行时行为一致:只有 opts.input 与 parsedInput 均为对象时才执行展开合并,否则直接以后者替换。测试 packages/tests/server/input.test.ts 用 @ts-expect-error 锁定了"对象 + 非对象"两种非法组合必须在类型层报错。
4.2 [MEDIUM] 输出校验失败会返回 500 而非 400
// ❌ 错误:运行时返回 { id: 123 },但 schema 要求 id 是 string
const proc = publicProcedure
.output(z.object({ id: z.string() }))
.query(() => ({ id: 123 }));
// ✅ 正确:返回与 schema 一致的数据
const proc = publicProcedure
.output(z.object({ id: z.string() }))
.query(() => ({ id: '123' }));
如果 .output() 校验失败,tRPC 返回 INTERNAL_SERVER_ERROR(500)而非 BAD_REQUEST,因为服务端产出了非法数据。对应状态码映射见 getHTTPStatusCode.ts(BAD_REQUEST: 400、INTERNAL_SERVER_ERROR: 500),JSON-RPC 错误码见 rpc/codes.ts(BAD_REQUEST: -32600、INTERNAL_SERVER_ERROR: -32603)。
4.3 [HIGH] 无限查询 cursor 必须用 .nullish() 而非 .optional()
这是 SKILL 文档中风险等级最高的一条,源于真实 issue(trpc/trpc#6862):
// ❌ 错误:optional 允许 undefined,但 React Query 失效重取时传 cursor: undefined
const proc = publicProcedure
.input(z.object({ cursor: z.string().optional() }))
.query(({ input }) => {
return { items: [], nextCursor: input.cursor };
});
// ✅ 正确:nullish = nullable + optional,显式允许 null
const proc = publicProcedure
.input(z.object({ cursor: z.string().nullish() }))
.query(({ input }) => {
return { items: [], nextCursor: input.cursor };
});
背景原因:React Query 在失效重取(invalidation refetch)等内部流程中会以 cursor: undefined 调用 query;若 schema 仅声明 .optional()(允许字段缺失,但一旦显式传入 undefined 仍可能触发校验失败)而未同时允许 null,就会校验不过。因此对"可能表示结束/暂无下一页"的游标,应使用同时兼容 null 与 undefined 的 .nullish()。
五、深入原理:一次调用的完整校验调用链
把前文散落的源码证据串起来,一次带校验的 procedure 调用的内部执行顺序是:
- HTTP 请求到达后,tRPC 按 JSON-RPC 格式解析 body(见 http.ts 与 rpc.ts);
- procedureBuilder.ts 的 input() 调用 parser.ts 的 getParseFn 把用户 schema 编译成统一
ParseFn,并注册createInputMiddleware;.output()同理注册createOutputMiddleware; - 请求执行进入 callRecursive,按注册顺序递归执行中间件链:输入校验中间件 → 用户
.use()中间件 → 解析器; - 输入中间件:
await opts.getRawInput()拿到原始参数 →parse()校验,失败抛TRPCError(BAD_REQUEST),成功则与上层输入合并后next({ input }); - 解析器执行:返回业务数据;
- 输出中间件:
parse(result.data)校验返回数据,失败抛TRPCError(INTERNAL_SERVER_ERROR, 'Output validation failed'),成功则把(可能被转换过的)数据继续回传; - 任意环节抛错都会被
getTRPCErrorFromUnknown归一化为TRPCError(见 procedureBuilder.ts),最终以对应 HTTP/JSON-RPC 错误码回给客户端。
同时,类型系统层面 procedure.ts 的 inferProcedureInput / inferProcedureOutput 会利用 inferParser 的结果为客户端生成精确的入参/出参类型,实现"schema 单点定义、全链路类型自动传播"。
import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
type HelloInput = inferProcedureInput<AppRouter['hello']>; // { name: string }
type HelloOutput = inferProcedureOutput<AppRouter['hello']>; // { greeting: string }
六、错误呈现与排错指引
输入校验失败时,客户端默认拿到的错误形状由 tRPC 的 errorFormatter 决定。若想拿到 Zod 的字段级明细(fieldErrors),可在 initTRPC.create() 中扩展格式化器——这正是 packages/tests/server/input.test.ts 采用的做法:
const t = initTRPC.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zod: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
经此配置后,客户端抛出的 TRPCClientError 会带有 error.data.zod.fieldErrors,例如测试快照中呈现的 { text: ["Invalid input: expected string, received undefined"] }。更系统的错误格式化方案可继续阅读 error-handling 与 www/docs/server/error-formatting.md。
七、要点速览与延伸阅读
| 主题 | 结论 | 源码/文档位置 |
|---|---|---|
| 输入校验失败 | 返回 BAD_REQUEST(HTTP 400) |
middleware.ts |
| 输出校验失败 | 返回 INTERNAL_SERVER_ERROR(HTTP 500),消息 'Output validation failed' |
middleware.ts |
多 .input() 合并 |
仅对象可合并;同名属性后者覆盖前者;运行时逐层 spread | input.test.ts |
| 自定义校验器 | 纯函数即校验器,throw 即失败,返回即"解析后"的 input | parser.ts |
| 多库兼容 | Zod/Yup/Superstruct/ArkType/Valibot/Effect/Typia/TypeBox 等;Standard Schema 优先 | validators.test.ts、validators.md |
| 无限查询游标 | 用 .nullish() 而非 .optional(),避免失效重取校验失败 |
SKILL.md(源自 issue #6862) |
建议延伸阅读(同仓库 skill 系列,均为前序/关联依赖):
- server-setup:
initTRPC、router、procedure 基础; - error-handling:校验错误如何以
BAD_REQUEST呈现、如何用errorFormatter暴露 Zod 字段错误; - middlewares:把"基础 input +
.use()"组合为可复用的中间件基础 procedure; - subscriptions:订阅场景下的输出校验与异步迭代器。
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 StartedRust0629
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证件照制作算法。Python07
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