tRPC Procedures 完全指南:使用 Query、Mutation 与可复用 Base Procedure 构建类型安全后端
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
导读
Procedure 是 tRPC 暴露给客户端的最小 API 单元——它定义了"客户端能调用什么",也是全栈类型安全的起点。本文基于 tRPC 官方文档《Define Procedures》并结合仓库内@trpc/server的源码实现,系统讲解 Query / Mutation / Subscription 三类 procedure 的语义、基于不可变 builder 模式的编写方法、publicProcedure+ 可复用 Base Procedure 的最佳实践,以及用inferProcedureBuilderResolverOptions抽取 resolver 的类型推导技巧。读完你既能照例写出可运行的 procedure,也能理解.input()、.use()、.query()等链式调用在底层如何被编译为一张"中间件调用链"。
什么是 Procedure:客户端能调用的三种后端函数
在 tRPC 中,Procedure(过程)就是暴露给客户端、可被远程调用的后端函数。根据用途分为三类,其类型定义见 procedure.ts:
export const procedureTypes = ['query', 'mutation', 'subscription'] as const; export type ProcedureType = (typeof procedureTypes)[number];| 类型 | 用途 | 说明 |
|---|---|---|
Query | 获取数据 | 一般不改动任何数据,可安全地 GET、缓存、并发执行 |
Mutation | 发送数据 | 常用于 create / update / delete 等写操作 |
Subscription | 订阅实时数据 | 多数场景用不到;tRPC 为其准备了专门文档 |
订阅(Subscription)的完整说明见 subscriptions 指南。
Procedure 是 tRPC 中非常灵活的"后端函数原语"。它采用不可变 builder(immutable builder)模式——每次调用.input()、.use()等链式方法都会返回一个新的 builder 对象而不会修改旧的,因此你可以先创建可复用的 Base Procedure,让多个 procedure 共享同一套鉴权、限流、日志等行为。
编写 Procedure:从t.procedure出发
你在 tRPC 初始化阶段创建的t对象会返回一个初始的t.procedure,所有其他 procedure 都构建于它之上。这是官方文档给出的最小示例:
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.context<{ signGuestBook: () => Promise<void> }>().create(); export const router = t.router; export const publicProcedure = t.procedure; const appRouter = router({ // Queries are the best place to fetch data hello: publicProcedure.query(() => { return { message: 'hello world', }; }), // Mutations are the best place to do things like updating a database goodbye: publicProcedure.mutation(async (opts) => { await opts.ctx.signGuestBook(); return { message: 'goodbye!', }; }), });几个要点:
router({ ... })将一组 procedure 按命名空间收拢成appRouter,hello/goodbye即对外的调用路径;- Query 是取数据的最佳位置,Mutation 是更新数据库等副作用操作的最佳位置;
- resolver 函数可以异步,并接收统一的
opts参数。
底层:t.procedure从哪来
查看 initTRPC.ts 中create()的实现可以看到:procedure正是由createBuilder<$Root['ctx'], $Root['meta']>({ meta: opts?.defaultMeta })创建的ProcedureBuilder实例,它与router、middleware、mergeRouters、createCallerFactory一起构成返回的"根对象"。initTRPC本身在文件末尾以new TRPCBuilder()导出(见同文件 L221),每个后端建议只初始化一次,随后通过模块导出共享t及其派生物。
resolver 收到的opts里到底有什么
resolver 的参数类型由 procedureBuilder.ts 中的ProcedureResolverOptions定义:
export interface ProcedureResolverOptions<TContext, _TMeta, TContextOverrides, TInputOut> { ctx: Simplify<Overwrite<TContext, TContextOverrides>>; // 上下文(可能已被中间件覆盖/收窄) input: TInputOut extends UnsetMarker ? undefined : TInputOut; // 经输入校验后的入参 signal: AbortSignal | undefined; // 请求的 AbortSignal,可用于取消 path: string; // 该 procedure 的完整调用路径 batchIndex?: number; // 批量请求中的调用序号(批处理时存在) }也就是说,上面goodbye中解构出的opts.ctx就是你在 Context 中声明的{ signGuestBook: () => Promise<void> },并且类型在编译期被完整保留——这正是 tRPC"端到端类型安全"的根基之一。关于 Context 的完整讲解见 context.md。
可复用 Base Procedure:publicProcedure与extends模式
tRPC 官方推荐的通用模式是:把t.procedure重命名导出为publicProcedure,为它腾出命名空间,再基于它创建面向特定场景的具名 procedure 一并导出。这个模式叫做Base Procedures,是 tRPC 中实现"行为/代码复用"的关键模式——几乎所有应用都会用到它。
下面的示例定义了两个 Base Procedure:
authedProcedure:断言用户已登录;organizationProcedure:基于authedProcedure,接收organizationId并校验用户属于该组织。
import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; type Organization = { id: string; name: string; }; type Membership = { role: 'ADMIN' | 'MEMBER'; Organization: Organization; }; type User = { id: string; memberships: Membership[]; }; type Context = { /** * User is nullable */ user: User | null; }; const t = initTRPC.context<Context>().create(); export const publicProcedure = t.procedure; // procedure that asserts that the user is logged in export const authedProcedure = t.procedure.use(async function isAuthed(opts) { const { ctx } = opts; // `ctx.user` is nullable if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return opts.next({ ctx: { // user value is known to be non-null now user: ctx.user, }, }); }); // procedure that asserts a user is a member of a specific organization export const organizationProcedure = authedProcedure .input(z.object({ organizationId: z.string() })) .use(function isMemberOfOrganization(opts) { const membership = opts.ctx.user.memberships.find( (m) => m.Organization.id === opts.input.organizationId, ); if (!membership) { throw new TRPCError({ code: 'FORBIDDEN', }); } return opts.next({ ctx: { Organization: membership.Organization, }, }); }); export const appRouter = t.router({ whoami: authedProcedure.query(async (opts) => { // user is non-nullable here const { ctx } = opts; return ctx.user; }), addMember: organizationProcedure .input( z.object({ email: z.string().email(), }), ) .mutation((opts) => { // ctx contains the non-nullable user & the organization being queried const { ctx } = opts; // input includes the validated email of the user being invited & the validated organizationId const { input } = opts; return '...'; }), });值得注意的类型收窄细节:
- 在
authedProcedure内部,ctx.user仍可为null,因此先做空值断言; - 通过
opts.next({ ctx: { user: ctx.user } })将非空用户重新注入 context 后,凡是从authedProcedure派生的 procedure,其ctx.user在类型上已被收窄为User(非空); organizationProcedure又把解析出的Organization追加进 ctx,于是下游addMember的opts.ctx同时包含非空user与被查询的组织,opts.input也自动合并了organizationId与新加的email字段。
提示:文档也指出,这是一个简化示例;真实项目中你通常会组合使用 Headers、Context、Middleware 与 Metadata 来完成用户认证与授权,相关话题可参考 authorization.md。
官方示例中 Base Procedure 的实际落地
在仓库的examples/minimal示例里,可以看到这套模式的最小工程化版本。文件 examples/minimal/src/server/trpc.ts 只做一件事:初始化 tRPC 后端、导出可复用的router与publicProcedure:
import { initTRPC } from '@trpc/server'; import { transformer } from '../shared/transformer.js'; /** * Initialization of tRPC backend * Should be done only once per backend! */ const t = initTRPC.create({ transformer, }); /** * Export reusable router and procedure helpers * that can be used throughout the router */ export const router = t.router; export const publicProcedure = t.procedure;其余路由文件只需要import { router, publicProcedure } from './trpc.js'即可按同一模式继续扩展,而不会重复初始化 tRPC 根对象。
底层原理:不可变 builder 如何累积中间件
publicProcedure(即t.procedure)的完整能力面由 procedureBuilder.ts 中的ProcedureBuilder接口定义,主要包括:
.input(schema):追加输入校验器;.output(schema):追加输出校验器;.use(fn):追加一个中间件(也接受另一个 middleware builder);.query(...)/.mutation(...)/.subscription(...):以给定 resolver 收尾,生成最终 procedure;.meta(meta):附加元数据(供路由层使用,见 metadata.md);.concat(builder):组合另一个 procedure builder(.unstable_concat为其弃用别名)。
链式调用的"不可变性"体现在 procedureBuilder.ts 的createNewBuilder中:每次调用都会基于当前_def通过mergeWithoutOverrides生成新 builder,同时将新增inputs与middlewares分别追加到已有数组末尾。也就是说,在authedProcedure上再.use(),不会污染publicProcedure本身,你可以安全地对同一个 Base 派生出多条不同行为的链。
query/mutation/subscription最终都进入createResolver(见同文件 L568-L610),它把真正的 resolver 包装成最后一个中间件存入middlewares数组;当 procedure 被调用时,procedureBuilder.ts 中的callRecursive会从下标 0 开始递归执行中间件链,每个中间件调用opts.next()时把最新的 ctx / input 传给下一层,直到命中最终 resolver——这也解释了为什么文档强调"在中间件里忘了return next()会拿不到结果"。
输入与输出校验器是"普通中间件"
再深入一层:.input()/.output()本身也是中间件。查看 middleware.ts:
createInputMiddleware会先通过opts.getRawInput()拿到原始输入,再交给 parser 解析;若解析失败,抛出的错误被统一包装为code: 'BAD_REQUEST'的TRPCError;createOutputMiddleware则先await next()拿到 resolver 的返回结果,成功后才执行输出解析;若输出校验失败,抛出code: 'INTERNAL_SERVER_ERROR'的TRPCError(message 为'Output validation failed')。
而.input(z.object(...))中的 schema 之所以能兼容 zod 之外的众多校验库,是因为 parser.ts 的getParseFn会按能力探测自动适配:函数式 parser、parseAsync(zod)、validateSync(yup)、create(superstruct)、assert(arktype / scale)以及 Standard Schema 都在支持之列。
认证 / 授权错误码的选择:TRPCError 与 code 语义
上例中鉴权失败抛出TRPCError({ code: 'UNAUTHORIZED' }),非组织成员则抛FORBIDDEN。tRPC 的错误码枚举定义在 codes.ts,数值参考了 JSON-RPC 2.0 规范,并对齐 HTTP 4xx/5xx 语义,例如:
| code | 数值 | 对应 HTTP | 典型场景 |
|---|---|---|---|
BAD_REQUEST | -32600 | 400 | 请求/入参格式错误 |
UNAUTHORIZED | -32001 | 401 | 未登录 |
FORBIDDEN | -32003 | 403 | 已登录但无权限 |
NOT_FOUND | -32004 | 404 | 目标资源不存在 |
CONFLICT | -32009 | 409 | 状态冲突(如重复创建) |
TOO_MANY_REQUESTS | -32029 | 429 | 触发限流 |
INTERNAL_SERVER_ERROR | -32603 | 500 | 未捕获的未知错误 |
抛出的TRPCError类定义在 error/TRPCError.ts:构造函数接收{ message?, code, cause? },code为必填;未知异常在中间件链中被捕获后会统一转换成INTERNAL_SERVER_ERROR的TRPCError(见 procedureBuilder.ts 与getTRPCErrorFromUnknown)。错误如何整形、如何被客户端读取,参见 error-handling.md。
推断 Base Procedure 的选项类型:inferProcedureBuilderResolverOptions
除了可以推断 procedure 的输入与输出类型之外,你还能用inferProcedureBuilderResolverOptions推断某个 procedure builder(或 Base Procedure)的resolver 选项类型。
这个类型工具最适合给函数参数声明类型,典型场景是:把 procedure 的 handler(主要执行逻辑)从 router 定义中分离出来,或编写一个能同时服务于多个 procedure 的公共辅助函数。
import { inferProcedureBuilderResolverOptions, initTRPC, TRPCError, } from '@trpc/server'; import { z } from 'zod'; type Organization = { id: string; name: string }; type Membership = { role: 'ADMIN' | 'MEMBER'; Organization: Organization }; type User = { id: string; memberships: Membership[] }; type Context = { user: User | null }; const t = initTRPC.context<Context>().create(); export const publicProcedure = t.procedure; // procedure that asserts that the user is logged in export const authedProcedure = t.procedure.use(async function isAuthed(opts) { const { ctx } = opts; if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return opts.next({ ctx: { user: ctx.user } }); }); // mock prisma let prisma = {} as any; // procedure that asserts a user is a member of a specific organization export const organizationProcedure = authedProcedure .input(z.object({ organizationId: z.string() })) .use(function isMemberOfOrganization(opts) { const membership = opts.ctx.user.memberships.find( (m) => m.Organization.id === opts.input.organizationId, ); if (!membership) { throw new TRPCError({ code: 'FORBIDDEN' }); } return opts.next({ ctx: { Organization: membership.Organization } }); }); async function getMembersOfOrganization( opts: inferProcedureBuilderResolverOptions<typeof organizationProcedure>, ) { // input and ctx are now correctly typed! const { ctx, input } = opts; return await prisma.user.findMany({ where: { membership: { organizationId: ctx.Organization.id, }, }, }); } export const appRouter = t.router({ listMembers: organizationProcedure.query(async (opts) => { // use helper function! const members = await getMembersOfOrganization(opts); return members; }), });这里的 magic 在于:organizationProcedure上挂载的所有.use()与.input()带来的 context 收窄、input 合并,都会在inferProcedureBuilderResolverOptions<typeof organizationProcedure>中被精确还原——辅助函数getMembersOfOrganization无需自己再手写一遍参数类型,也永远与 Base Procedure 的演进保持同步。
类型定义的实现细节与既有测试
该类型工具的源码实现位于 procedureBuilder.ts:它从 builder 的 8 个类型参数中分别抽取TContext、TMeta、TContextOverrides、TInputOut,再套回ProcedureResolverOptions。代码中特意处理了两个边界:
- 若当前 builder 尚未声明 input,则把 input 推断为
unknown(而不是undefined),因为链上后续仍可能追加.input(); - 若 input 是对象类型,会额外附带一个索引签名
[keyAddedByInputCallFurtherDown: string]: unknown,以允许链尾继续新增输入字段。
仓库自带的行为测试在 procedureBuilder.test.ts:测试先用同一个模式构造authedProcedure,再断言authedProcedureHelperFn(参数类型为inferProcedureBuilderResolverOptions<typeof authedProcedure>)中opts.input为unknown、opts.ctx.user为非空User,随后在addPost、deletePost等多个 mutation / query 中直接复用该辅助函数——正是文档描述场景的可执行验证。
附:Subscriptions 与继续深入
tRPC v11 中的订阅以 AsyncIterable / SSE 为主要形态,编写方式、重连与鉴权策略与 query/mutation 差异较大。仓库官方将其单独成篇,本文不展开,详见 subscriptions.md(对应@trpc/server中SubscriptionProcedure的定义见 procedure.ts)。
如果你想继续深化本文涉及的周边概念,推荐按序阅读同一目录下的这些指南:
- routers.md:
t.router的完整能力与 router 合并 - context.md:本文
opts.ctx的来源与注入时机 - middlewares.md:
.use()中间件的完整 API 与组合方式 - validators.md:
.input()/.output()及各类校验库适配 - metadata.md:
.meta()与路由层元数据 - authorization.md:把 Base Procedure 用于真实鉴权体系
- error-handling.md:
TRPCError的错误码与格式化 - subscriptions.md:第三类 procedure 的专项指南
- infer-types.md:从 server 类型反推客户端类型
小结
可以把整篇文章浓缩为一句话:Procedure = 不可变 builder 上累积的输入校验器、中间件与最终 resolver 的统一封装。掌握三个层次即可应对绝大多数后端设计:先会用t.procedure写出裸的 query / mutation;再通过publicProcedure与 Base Procedure 把"登录、组织成员校验"这类横切逻辑固化为可复用且类型精确的底座;最后用inferProcedureBuilderResolverOptions把 handler 拆出去独立维护,让类型安全贯穿于模块边界之外。上述所有源码与示例都可以在packages/server与examples目录中对照阅读。
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考