首页
/ tRPC v9 路由(Router)定义与输入校验实战指南:从 Procedure 到 Method Chaining

tRPC v9 路由(Router)定义与输入校验实战指南:从 Procedure 到 Method Chaining

2026-09-08 09:17:09作者:史锋燃Gardner

本指南以 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 明确交代了三个贯穿始终的认知:

  1. Procedure 可以被视为 REST 端点的等价物(equivalent of a REST-endpoint)。一个 Procedure 就是对外暴露的一个可调用单元,携带输入(input)并返回输出(output),与 REST 中一个 POST /hello 之类的端点一一对应。
  2. 查询(query)与变更(mutation)在内部没有任何区别,差异纯粹是语义上的(semantics)。它们底层共用同一套调用、解析与错误处理机制,选择哪一种只影响客户端语义(如 React Query 中查询可缓存、变更不会重复触发)以及 HTTP 侧的方法映射。
  3. 定义路由的方式对 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():允许 textstring | null | undefined
  • 整个对象 schema 再套 .nullish():表示客户端甚至可以完全不传 input
  • 因此 resolveinput 的类型被推导为 { 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 版本里 textyup.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;

这份代码定义了两个无输入端点:hellobye,客户端分别通过 trpc.hellotrpc.bye 调用,输入为空时 resolve()input 形参都不必解构。

底层是如何“累积”这些过程的:从链式 builder 到扁平索引

当前仓库主干实现中,过程集合被最终收敛到统一的扁平结构中:router.ts 里的 step 函数会递归遍历路由树,把每个叶子过程以点号拼接的完整路径(dotted path) 为键写入 procedures 扁平表(procedures[newPath] = item),同时维护嵌套结构 record 用于生成类型与调用代理;getProcedureAtPath 则依据路径在表中查过程。这种“树状声明、扁平索引”的设计解释了为什么“链式多个端点”与“对象式嵌套路由”能统一寻址:无论声明形态如何,最终都落到 'hello''posts.list' 之类的点号路径上。

值得留意的工程细节是保留字校验:源码 router.tsreservedWords 定义于 L210-L221 附近)禁止过程或路由命名为 thencallapply,原因在于路由器对外暴露的可调用代理是 Promise/函数语义对象,若允许这些名字会产生 .then.call().apply() 冲突,破坏代理与类型推断;一旦使用会直接抛出 'Reserved words used in router() call: ...'。另外,当多条链或合并路由出现重复的叶子路径时,step 会抛出 Duplicate key: ${newPath},从源头拦截歧义端点。这些机制在 v9 文档示例的小型路由中不会触发,但当你把路由拆分成多文件再用 mergeRouters(详见 merging-routers)合并、或按需懒加载(源码提供 lazy() 包装,router.tsLazy / 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.tsgetParseFn 看校验器接入原理、从 router.ts 的过程扁平化与保留字约束看路由实现的工程细节,再配合 validators.test.ts 了解官方对各校验库的兼容性保障。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

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