tRPC 端到端类型安全实践(一):用 initTRPC 定义 Router 与 Procedure
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
本篇文章以 tRPC 仓库 landing 页教学代码 Step1.md 为骨架,系统讲解在 tRPC 中如何初始化根对象、用.input()接入 Zod 校验、编写.query()查询 procedure,并导出供前端使用的AppRouter类型。读完你将掌握 tRPC 后端"最小可用"的完整写法,理解initTRPC.create()、router、publicProcedure之间的源码级关系,并能立刻在仓库 examples/minimal 或自己的项目中复制落地。
Step1 在官方教程三步走中的位置
在 tRPC 官网落地页的"三步上手"区块(由 QuickIntro.tsx 渲染)中,Step1~Step3 构成一条完整的端到端链路:
- Define your procedures:先定义 procedure(本篇文章核心,对应 Step1.md);
- Create your HTTP server:用
createHTTPServer把 router 暴露成 HTTP 服务(对应 Step2.md); - Connect your client and start querying:客户端传入
AppRouter类型后获得类型推导与自动补全(对应 Step3.md)。
procedure 是构建后端的原子单元,它可组合,可以是 query、mutation 或 subscription;而 router 则用于聚合多个 procedure。Step1.md 给出的是最小但五脏俱全的例子,我们先逐行拆解它,再深入源码理解背后机制。
逐行拆解 Step1 的核心代码
import { initTRPC } from '@trpc/server'; import z from 'zod'; const t = initTRPC.create(); const router = t.router; const publicProcedure = t.procedure; const appRouter = router({ greeting: publicProcedure .input(z.object({ name: z.string() })) .query((opts) => { const { input } = opts; // ^? return `Hello ${input.name}` as const; }), }); export type AppRouter = typeof appRouter;1.initTRPC.create():初始化后端根对象
const t = initTRPC.create();initTRPC是@trpc/server导出的初始化入口。官方要求它在每个后端中只执行一次,之后再从t上解构出复用的构建器,而不是到处initTRPC.create()。从源码看,这一约定是有依据的:initTRPC本身是单例构建器,create()会一次性把"根配置(RootConfig)、procedure 构建器、middleware 工厂、router 工厂、mergeRouters、createCallerFactory"全部组装好(见 initTRPC.ts)。
create()还接受可选配置对象,例如transformer、errorFormatter、isDev、allowOutsideOfServer、defaultMeta。其中默认isDev为process.env.NODE_ENV !== 'production';更重要的是,若在不支持服务器环境的地方调用create()且未显式开启allowOutsideOfServer: true,源码会直接抛错以拦截误用(见 initTRPC.ts)。
2. 导出可复用的router与publicProcedure
const router = t.router; const publicProcedure = t.procedure;这只是简单的别名赋值,却是一种重要的工程约定:把「初始化」与「业务定义」解耦。仓库推荐的工程习惯是把这两行放进独立的trpc.ts,让所有子路由 import 它,从而避免循环依赖。仓库示例 examples/minimal/src/server/trpc.ts 就是标准模板,它还演示了在create()时传入transformer的写法。
3.router({...}):用对象聚合 procedure
const appRouter = router({ greeting: publicProcedure /* ... */, });router 的键就是 procedure 的路径名,值可以是单个 procedure,也可以是嵌套的子 router 对象(例如user: { list, byId, create })。examples/minimal/src/server/index.ts 展示了嵌套写法:user.list、user.byId、user.create。procedure 类型层面的所有信息——输入解析器、输出类型、错误 shape——都会沿 router 结构被精确推导出来。
4..input(z.object({ name: z.string() })):接入输入校验
.input(z.object({ name: z.string() })).input()接收一个输入解析器。任何实现了对应接口的校验库都能接入。仓库源码 parser.ts 明确列出了这些形态:
| 校验库 / 形态 | 源码识别方式 | 说明 |
|---|---|---|
| zod | .parse/.parseAsync | 最常用,_input/_output可区分收窄前后类型 |
| valibot | .schema/.parse | 兼容多种版本形态 |
| yup | .validateSync | Schema 校验 |
| superstruct | .create | Schema 校验 |
| arktype | .assert | 函数形式 schema,需走assert而非直接调用 |
| Standard Schema | ~standard属性 | 通用标准 schema 接口(如 effect 等库) |
| 自定义函数 | (input: unknown) => TInput | 手写校验函数,例如typeof val === 'string' |
getParseFn(见 parser.ts)会在运行时按上述顺序探测解析函数。因此你可以用 zod,也可以换 yup、superstruct 或纯手写校验函数,不依赖任何特定库。
为什么使用校验库而不仅是 TS 类型?因为 HTTP 到达的数据在类型层面是
unknown,TS 类型在编译后被擦除,无法做运行时防御。.input()的解析器在运行时完成「校验 + 转型」,并把结果类型精确注入到 resolver 的opts.input中。这就是"一次定义、运行时与类型层同时生效"的关键。
5..query((opts) => {...}):写真正的业务逻辑
.query((opts) => { const { input } = opts; return `Hello ${input.name}` as const; })query 的 resolver 接收一个参数对象opts,其类型由 procedure 构建链上的上下文与输入解析器共同决定。从源码 procedureBuilder.ts 可以看到opts的标准字段:ctx(上下文)、input(经解析器校验后的输入)、signal(请求的 AbortSignal)、path(procedure 路径),批处理场景下还有batchIndex。Step1 代码中的// ^?是文档 twoslash 类型标注,展示编辑器里input已被精确推断为{ name: string }。
除了.query(),procedure 还可调用.mutation()(写操作、HTTP POST)与.subscription()(订阅实时流),三者共享同一构建链上的输入校验与中间件能力。本步只演示 query 即可覆盖最小闭环。
6.export type AppRouter = typeof appRouter:把类型桥接给前端
export type AppRouter = typeof appRouter;这行导出是整个 tRPC 类型体系的灵魂。它只导出类型、不导出实现——client 端使用import type { AppRouter } from './server'即可(import type会在编译期被完全擦除,不产生任何运行时代码,见 Step3.md 与 quickstart.mdx)。前端拿到的只是 router 的类型签名,实现细节完全封装在服务端,不会泄漏。
源码视角:create() 之后我们拿到了什么
initTRPC.create()返回的根对象上挂着 5 个核心构建入口(见 initTRPC.ts 与接口定义 initTRPC.ts):
procedure:procedure 构建器,是.input()/.query()/.mutation()/.use()链式调用的起点;router:创建 router 的工厂,把 procedure 或子 router 聚合为类型树;middleware:创建可复用中间件的工厂;mergeRouters:把多个 router 合并成一个(常用于拆分业务模块);createCallerFactory:创建服务端直接调用器,用于在服务端无 HTTP 地调用 procedure(测试/SSR 常用)。
当调用.input()、.query()时,实际上是在构建一个不可变的类型链:每次调用都返回新的构建器,把输入解析器、resolver 等信息累积进内部定义ProcedureBuilderDef(见 procedureBuilder.ts)。类型层则借助UnsetMarker区分"尚未设置输入"与"已设置输入"的状态,从而在未设置.input()时把 resolver 的input推断为undefined,避免误用。
因此可以推断:你看到的一切类型安全都发生在编译期推导 + 运行时解析器执行两个层面,二者由同一段构建代码串联,不会出现类型与行为不一致。
让 Step1 跑起来:接入 HTTP 服务器与客户端
Step1.md 定位为三步流程的第一步,配合仓库中同一目录的 Step2.md 与 Step3.md 即可形成可运行闭环:
// server/index.ts —— 对应 Step2 import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { appRouter } from './appRouter'; createHTTPServer({ router: appRouter }).listen(3000); // 服务监听在 3000 端口@trpc/server/adapters/standalone是零依赖、基于 Node 原生http的适配器,适合快速原型与本地联调。从源码 standalone.ts 可以看到它支持basePath选项(默认'/',可设为/trpc/等),请求路径会去掉basePath前缀后交给统一的nodeHTTPRequestHandler处理。更复杂的场景可替换为 Express、Fastify、Next.js、AWS Lambda 等适配器(参见 adapters-intro.md 与 standalone.md)。
客户端侧(对应 Step3.md):
import { createTRPCClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from './server'; // type-only 导入,编译期擦除 const trpc = createTRPCClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000' }), ], }); const res = await trpc.greeting.query({ name: 'John' }); // res 的类型被自动推断为 "Hello John"createTRPCClient<AppRouter>只接收类型参数,把AppRouter的类型签名"注入"客户端对象。httpBatchLink会把同一时间段内发起的多次调用合并成一次 HTTP 请求批量发送,其内部 DataLoader 支持maxURLLength、maxItems等分割参数(见 httpBatchLink.ts 的默认值:两者默认均为Infinity,即不做拆分)。
完整可运行的端到端示例可直接阅读仓库 examples/minimal:其 index.ts 中定义了user.list(query)、user.byId(带z.string()输入的 query)、user.create(带对象输入的 mutation)以及一个异步迭代器 query,与 Step1 的模式完全一致。
工程化落地清单
把 Step1.md 的模式应用到自己项目时,官方推荐按如下文件组织(详见 quickstart.mdx):
. ├── server/ │ ├── trpc.ts # initTRPC.create() + 导出 router / publicProcedure │ ├── appRouter.ts # 业务 procedure 定义 + export type AppRouter │ └── index.ts # HTTP 服务器启动 └── client/ └── index.ts # createTRPCClient<AppRouter> + 调用需要留意的硬性前提:
initTRPC.create()只执行一次,且应放在独立模块中;- 版本要求:tRPC 要求 TypeScript 版本满足要求(当前仓库官方文档标注 TypeScript >= 5.7.2),并强烈建议开启
"strict": true(见 quickstart.mdx 的 Requirements 说明); - 子路由组织:业务量大时可拆分为多个子 router,再用
t.mergeRouters合并; - 输入校验:任何自定义解析器都要保证"校验失败必须抛错",这样 procedure 才不会以错误输入继续执行。
更多进阶内容可继续阅读仓库文档中与 Step1 直接相关的专题:procedures.md(procedure 全能力)、validators.md(输入解析器详解)、routers.md(router 与初始化约定),它们与 Step1.md 一脉相承,可帮助你从"能跑"走向"用得专业"。
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考