tRPC v11 输入与输出验证器(Input Output Validators)完整指南
2026/9/10 14:00:08 网站建设 项目流程

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)声明校验逻辑;这些验证器同时承担两层职责:

  1. 运行时校验:在真正执行查询/变更逻辑之前(或之后)验证数据,非法输入直接返回校验错误;
  2. 类型推导:基于验证器的类型描述推导 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" 小节):

  1. 只能链式合并"对象类型"——合并底层通过"展开对象属性"(spread)实现,因此非对象类型(如z.string())无法合并;
  2. 同名属性时后定义的生效——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:

  1. 先调用next()拿到 resolver 的实际返回值;
  2. 若下游失败(result.ok === false)则直接透传错误,不做输出校验;
  3. 若成功则调用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.assertArkTypeparser.assert.bind(parser)
函数且非 Standard SchemaValibot(≥ v0.31.0)、自定义函数直接调用函数
parser.parseAsyncZod(异步)parser.parseAsync.bind(parser)
parser.parseZod、Valibot(< v0.13.0)parser.parse.bind(parser)
parser.validateSyncYupparser.validateSync.bind(parser)
parser.createSuperstructparser.create.bind(parser)
parser.assert(非函数形态)scale-tsassert后原样返回值
'~standard' in parserStandard 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/typeboxwrap()包装成 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 中现有的全部受支持接口(ParserZodEsqueParserValibotEsqueParserArkTypeEsqueParserStandardSchemaEsque等)以及统一的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 中本质是一段可被调用的解析函数,运行时位于中间件链上。一次带校验的调用完整链路是:

  1. HTTP 层取得原始输入(getRawInput());
  2. 按顺序执行输入解析中间件:parse(rawInput)失败则抛BAD_REQUEST,成功则与上一层对象输入做 spread 合并;
  3. 进入你的中间件与 resolver;
  4. 返回后执行输出解析中间件:失败则抛INTERNAL_SERVER_ERROROutput validation failed),成功则将解析结果返回客户端。

相关阅读

  • 中间件(middlewares):本文 Input Merging 中公共输入的主要使用场景;
  • 订阅(subscriptions):订阅输出校验的完整示例;
  • procedures 与 routers:procedure 与路由的组织方式;
  • 错误处理(error-handling):BAD_REQUESTINTERNAL_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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询