tRPC v11 输入与输出验证器(Input & Output Validators)完整指南
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
本篇技术指南以 tRPC 官方文档 www/docs/server/validators.md 为核心骨架,结合当前仓库中 packages/server 的源码实现与测试用例,系统讲解 tRPC procedure(查询 / 变更 / 订阅)如何通过.input()与.output()定义运行时校验逻辑,并自动推导类型。读完本文,你将掌握输入校验、输出校验、.input()链式合并(Input Merging)以及 Zod、Valibot、ArkType、effect、TypeBox 等主流校验库的接入方式,并理解 tRPC 底层如何通过一套统一的 "parser" 抽象做到与校验库解耦。
一、tRPC 验证器是什么
tRPC 的核心价值是"端到端类型安全",而**验证器(validator)**是其中承上启下的一环:procedure 可以为它的输入(input)和/或输出(output)声明校验逻辑;这些验证器同时承担两层职责:
- 运行时校验:在真正执行查询/变更逻辑之前(或之后)验证数据,非法输入直接返回校验错误;
- 类型推导:基于验证器的类型描述推导 procedure 的入参类型与返回类型,客户端与服务器共享同一份类型。
类型推导优先走 Standard Schema)。
源码层面,tRPC 内部维护了一张针对主流校验库的"鸭子类型"接口表,例如:
ParserZodEsque(含_input/_output,对应 Zod 的输入输出类型标记);ParserValibotEsque(含schema._types);ParserArkTypeEsque(含inferIn/infer);ParserYupEsque(含validateSync);ParserSuperstructEsque(含create);ParserScaleEsque(含assert);ParserCustomValidatorEsque(纯函数(input: unknown) => ...)。
只要某个库的对象形态满足其中一种接口,就能被 tRPC 接受为验证器——这正是"无需为每个库写适配器"的底层原因。
二、输入验证器(Input Validators)
定义输入验证器后,tRPC 会在调用 procedure 前检查入参,不合法则返回校验错误。用法是在 procedure 上调用.input():
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input( z.object({ name: z.string(), }), ) .query((opts) => { const name = opts.input.name; // 类型自动推导为 string return { greeting: `Hello ${opts.input.name}`, }; }), });几点值得注意:
.input()支持同步与异步解析(async parse),见下方测试中 Zod 的.refine(async ...)用法;- 校验通过后,resolver(即
.query()回调)里opts.input已被解析并具备完整类型; - 一旦校验失败,服务器端会抛出错误。从 middleware.ts 源码可见,输入解析失败会被包装成
TRPCError({ code: 'BAD_REQUEST' });在 HTTP 层对应 400 状态码(见 getHTTPStatusCode.ts)。
仓库测试 validators.test.ts 直接验证了该行为——用 Zod v3 的z.number()校验却传入字符串'123',客户端会收到形如"code": "invalid_type"、"Expected number, received string"的TRPCClientError。
2.1 Input Merging:链式合并输入
.input()可以被多次调用进行堆叠(stacking),从而拼装出更复杂的输入类型。典型场景是:把一组 procedure 共同需要的公共输入定义在中间件(middleware)或基础 procedure 中,再由各 procedure 追加各自的输入。
合并的规则需要记住两点(来源:原文档 "Input Merging" 小节):
- 只能链式合并"对象类型"——合并底层通过"展开对象属性"(spread)实现,因此非对象类型(如
z.string())无法合并; - 同名属性时后定义的生效——
property冲突时靠后一次.input()覆盖靠前的结果。
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) => { const input = opts.input; // 这里已是 { townName: string } console.log(`Handling request with user from: ${input.townName}`); return opts.next(); }); export const appRouter = t.router({ hello: baseProcedure .input( z.object({ name: z.string(), }), ) .query((opts) => { const input = opts.input; // 自动合并为 { townName: string; name: string } return { greeting: `Hello ${input.name}, my friend from ${input.townName}`, }; }), });源码印证了这套行为:
- 每次
.input()都会把 parser 追加进_def.inputs数组,并注入一个"输入中间件",见 procedureBuilder.ts; - 在 middleware.ts 中,当上一级中间件已传入对象输入(
opts.input)而本次解析结果也是对象时,二者通过{ ...opts.input, ...parsedInput }合并——这正是"后写覆盖先写"的来源;若解析结果不是对象,则以本次解析结果直接取代; - 类型层面,多次
.input()的输入通过IntersectIfDefined交叉类型叠加;同时类型系统会在编译期强制"必须解析为对象类型""可选解析器不能叠加到必选解析器之后"等约束(见 procedureBuilder.ts 的TypeError<...>分支)。
三、输出验证器(Output Validators)
tRPC 通过推导 resolver 的返回类型已经提供了自动类型安全,因此输出校验并非必需。原文档给出的两个典型动机是:
- 数据来自不可信来源(例如第三方服务、外部 API 回包),需要确认其符合预期结构;
- 防止向客户端返回超出必要范围的数据(比如误把整条数据库记录连同内部字段一起返回),把返回边界收敛到输出 schema 允许的字段上。
:::info 输出校验如果失败,服务器将以INTERNAL_SERVER_ERROR响应。 :::
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .output( z.object({ greeting: z.string(), }), ) .query((opts) => { return { greeting: 'hello world', }; }), });源码实现说明:.output()与.input()相似,在 procedureBuilder.ts 中把解析函数包装成"输出中间件"。区别在于执行时机——见 middleware.ts:
- 先调用
next()拿到 resolver 的实际返回值; - 若下游失败(
result.ok === false)则直接透传错误,不做输出校验; - 若成功则调用
parse(result.data),失败时抛出TRPCError({ message: 'Output validation failed', code: 'INTERNAL_SERVER_ERROR' })。
这解释了为什么输出校验失败对应 500 而非 400:问题出在服务器自己的返回契约没有被遵守,属于内部错误。
3.1 订阅(subscription)的输出校验
订阅(subscription)的 resolver 是一个异步迭代器(async iterable)。由于输出校验中间件解析的是result.data,对于异步迭代器数据流需要在每个产出值上应用校验逻辑。tRPC 允许对订阅使用与 query/mutation 相同的.output()技术,具体示例见 subscriptions 指南中的 Output validation 小节。
四、最朴素的验证器:一个普通函数
验证器本质并不神秘——"它只是 TypeScript"。你可以完全依赖第三方库,用一个普通函数写验证器,该方法无需任何额外依赖:
import { initTRPC } from '@trpc/server'; const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .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((opts) => { const { input } = opts; // 类型为 string return `hello ${input}`; }), }); export type AppRouter = typeof appRouter;函数验证器在 parser.ts 中对应ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput,它既支持同步函数也支持返回 Promise 的异步函数。
:::info 原文档建议:除非确有特殊需求,否则不要自行封装验证器,大多数场景应优先选用现成的验证库(见下节)。理解函数验证器的价值在于破除"魔法"——tRPC 只依赖你提供的可调用解析逻辑。 :::
五、主流验证库集成(Library Integrations)
tRPC 与大量流行的校验/解析库开箱即用,包括任何遵循 Standard Schema 的库。下面按官方文档逐一给出示例(tRPC 官方维护这些库的兼容性支持)。
库之所以能"零适配器"接入,根本原因是 parser.ts 中的getParseFn()会按以下优先级探测 parser 的形态并返回统一的ParseFn:
| 探测条件 | 识别目标 | 统一后的调用 |
|---|---|---|
typeof parser === 'function' && parser.assert | ArkType | parser.assert.bind(parser) |
| 函数且非 Standard Schema | Valibot(≥ v0.31.0)、自定义函数 | 直接调用函数 |
parser.parseAsync | Zod(异步) | parser.parseAsync.bind(parser) |
parser.parse | Zod、Valibot(< v0.13.0) | parser.parse.bind(parser) |
parser.validateSync | Yup | parser.validateSync.bind(parser) |
parser.create | Superstruct | parser.create.bind(parser) |
parser.assert(非函数形态) | scale-ts | assert后原样返回值 |
'~standard' in parser | Standard Schema(effect、TypeBox 等) | ~standard.validate,含 issues 则抛StandardSchemaV1Error |
换言之,无论哪种库,最终都归一到(value: unknown) => Promise<T> | T这一解析函数上。
5.1 与 Zod 搭配
Zod 是 tRPC 的默认推荐:它拥有强大的生态,适合在代码库的多个部分复用;如果你没有特别偏好、又希望有一款功能全面、不会限制未来需求扩展的库,Zod 是很好的起点。
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.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;5.2 与 Yup 搭配
Yup 面向对象风格的.string().required()链式写法在这里同样成立:
import { initTRPC } from '@trpc/server'; import * as yup from 'yup'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input( yup.object({ name: yup.string().required(), }), ) .output( yup.object({ greeting: yup.string().required(), }), ) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.3 与 Superstruct 搭配
import { initTRPC } from '@trpc/server'; import { object, string } from 'superstruct'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input(object({ name: string() })) .output(object({ greeting: string() })) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.4 与 scale-ts 搭配
scale-ts(SCALE 编解码体系的 TypeScript 实现)以$.field('name', $.str)的方式声明结构:
import { initTRPC } from '@trpc/server'; import * as $ from 'scale-codec'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input($.object($.field('name', $.str))) .output($.object($.field('greeting', $.str))) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.5 与 Typia 搭配
Typia 通过类型级变换在编译期生成零运行时开销的断言函数:
import { initTRPC } from '@trpc/server'; import typia from 'typia'; import { v4 } from 'uuid'; import { IBbsArticle } from '../structures/IBbsArticle'; const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ store: publicProcedure .input(typia.createAssert<IBbsArticle.IStore>()) .output(typia.createAssert<IBbsArticle>()) .query(({ input }) => { return { id: v4(), writer: input.writer, title: input.title, body: input.body, created_at: new Date().toString(), }; }), }); export type AppRouter = typeof appRouter;5.6 与 ArkType 搭配
ArkType 提供字符串化的类型描述语法type({ name: 'string' }):
import { initTRPC } from '@trpc/server'; import { type } from 'arktype'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure.input(type({ name: 'string' })).query((opts) => { return { greeting: `hello ${opts.input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.7 与 effect 搭配
effect 的Schema需要先包装为 Standard Schema v1 形态(Schema.standardSchemaV1(...))再交给 tRPC:
import { initTRPC } from '@trpc/server'; import { Schema } from 'effect'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input(Schema.standardSchemaV1(Schema.Struct({ name: Schema.String }))) .output(Schema.standardSchemaV1(Schema.Struct({ greeting: Schema.String }))) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.8 与 Valibot 搭配
Valibot 以极小的打包体积见长,API 风格与 Zod 接近(v.object({ name: v.string() })):
import { initTRPC } from '@trpc/server'; import * as v from 'valibot'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input(v.object({ name: v.string() })) .output(v.object({ greeting: v.string() })) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.9 与 @robolex/sure 搭配
@robolex/sure允许自定义错误类型与抛错函数。它内置了便捷的err()包装器:schema 返回二元组(是否通过 / 结果或错误),通过则交出结果,否则抛出结果作为错误:
// @robolex/sure 内部实现示意 export const err = (schema: any) => (input: any) => { const [good, result] = schema(input); if (good) return result; throw result; };import { err, object, string } from '@robolex/sure'; import { initTRPC } from '@trpc/server'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input( err( object({ name: string, }), ), ) .output( err( object({ greeting: string, }), ), ) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;5.10 与 TypeBox 搭配
TypeBox 的Type.Object(...)需要借助@typeschema/typebox的wrap()包装成 tRPC 可识别的形态:
import { Type } from '@sinclair/typebox'; import { initTRPC } from '@trpc/server'; import { wrap } from '@typeschema/typebox'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure .input(wrap(Type.Object({ name: Type.String() }))) .output(wrap(Type.Object({ greeting: Type.String() }))) .query(({ input }) => { return { greeting: `hello ${input.name}`, }; }), }); export type AppRouter = typeof appRouter;六、贡献你自己的验证库
如果你在维护某个支持 tRPC 的验证库,欢迎为本页提交 PR,补充与其他示例等价的用法及你的文档链接。技术要点是:
- 绝大多数情况下,接入 tRPC 只需满足若干既有类型接口之一;
- 强烈推荐让库遵循 Standard Schema(
~standard协议),这样 tRPC 将自动识别,无需任何额外适配代码; - 在部分场景下,tRPC 也可能接受提交 PR 来新增一种受支持的接口——建议先开 issue 讨论方案。
你可以直接阅读 parser.ts 中现有的全部受支持接口(ParserZodEsque、ParserValibotEsque、ParserArkTypeEsque、ParserStandardSchemaEsque等)以及统一的getParseFn()解析/校验逻辑,以此作为兼容性判断基准。
七、结合测试验证完整流程
packages/tests/server/validators.test.ts(共 900+ 行)对本文覆盖的所有机制做了端到端验证:
- 无验证器时
opts.input类型为undefined; - Zod v3 / v4 的同步与异步校验、非法输入返回的客户端错误快照;
- Valibot、Yup、Superstruct、scale-ts、ArkType、runtypes、effect、myzod 等多库在真实 client↔server 链路上的行为;
- 多条
.input()链式合并后的类型与运行时对象合并结果。
这一测试文件同时佐证了一个结论:验证器在 tRPC 中本质是一段可被调用的解析函数,运行时位于中间件链上。一次带校验的调用完整链路是:
- HTTP 层取得原始输入(
getRawInput()); - 按顺序执行输入解析中间件:
parse(rawInput)失败则抛BAD_REQUEST,成功则与上一层对象输入做 spread 合并; - 进入你的中间件与 resolver;
- 返回后执行输出解析中间件:失败则抛
INTERNAL_SERVER_ERROR(Output validation failed),成功则将解析结果返回客户端。
相关阅读
- 中间件(middlewares):本文 Input Merging 中公共输入的主要使用场景;
- 订阅(subscriptions):订阅输出校验的完整示例;
- procedures 与 routers:procedure 与路由的组织方式;
- 错误处理(error-handling):
BAD_REQUEST、INTERNAL_SERVER_ERROR等错误码的处理与格式化; - 源码:parser.ts、procedureBuilder.ts、middleware.ts。
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考