首页
/ tRPC Validators 权威指南:用 .input() / .output() 实现端到端类型安全校验

tRPC Validators 权威指南:用 .input() / .output() 实现端到端类型安全校验

2026-09-08 17:21:51作者:董斯意

导读

本文将系统讲解 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.tsmiddleware.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-setupinitTRPC / 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.tsdouble 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_ERRORTRPCError。同时它不会覆盖 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),其次是 .parsevalidateSynccreateassert,若都不匹配则抛出 '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 v3zod v4yupsuperstructarktypevalibotruntypesmyzodscale-codeceffect 等多个库的端到端读写用例都能在 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.inputparsedInput 均为对象时才执行展开合并,否则直接以后者替换。测试 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.tsBAD_REQUEST: 400INTERNAL_SERVER_ERROR: 500),JSON-RPC 错误码见 rpc/codes.tsBAD_REQUEST: -32600INTERNAL_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 调用的内部执行顺序是:

  1. HTTP 请求到达后,tRPC 按 JSON-RPC 格式解析 body(见 http.tsrpc.ts);
  2. procedureBuilder.ts 的 input() 调用 parser.ts 的 getParseFn 把用户 schema 编译成统一 ParseFn,并注册 createInputMiddleware.output() 同理注册 createOutputMiddleware
  3. 请求执行进入 callRecursive,按注册顺序递归执行中间件链:输入校验中间件 → 用户 .use() 中间件 → 解析器;
  4. 输入中间件await opts.getRawInput() 拿到原始参数 → parse() 校验,失败抛 TRPCError(BAD_REQUEST),成功则与上层输入合并后 next({ input })
  5. 解析器执行:返回业务数据;
  6. 输出中间件parse(result.data) 校验返回数据,失败抛 TRPCError(INTERNAL_SERVER_ERROR, 'Output validation failed'),成功则把(可能被转换过的)数据继续回传;
  7. 任意环节抛错都会被 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-handlingwww/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.tsvalidators.md
无限查询游标 .nullish() 而非 .optional(),避免失效重取校验失败 SKILL.md(源自 issue #6862)

建议延伸阅读(同仓库 skill 系列,均为前序/关联依赖):

  • server-setupinitTRPC、router、procedure 基础;
  • error-handling:校验错误如何以 BAD_REQUEST 呈现、如何用 errorFormatter 暴露 Zod 字段错误;
  • middlewares:把"基础 input + .use()"组合为可复用的中间件基础 procedure;
  • subscriptions:订阅场景下的输出校验与异步迭代器。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389