news 2026/9/10 14:12:15

tRPC Procedures 完全指南:使用 Query、Mutation 与可复用 Base Procedure 构建类型安全后端

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
tRPC Procedures 完全指南:使用 Query、Mutation 与可复用 Base Procedure 构建类型安全后端

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 按命名空间收拢成appRouterhello/goodbye即对外的调用路径;
  • Query 是取数据的最佳位置,Mutation 是更新数据库等副作用操作的最佳位置;
  • resolver 函数可以异步,并接收统一的opts参数。

底层:t.procedure从哪来

查看 initTRPC.ts 中create()的实现可以看到:procedure正是由createBuilder<$Root['ctx'], $Root['meta']>({ meta: opts?.defaultMeta })创建的ProcedureBuilder实例,它与routermiddlewaremergeRouterscreateCallerFactory一起构成返回的"根对象"。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:publicProcedureextends模式

tRPC 官方推荐的通用模式是:t.procedure重命名导出为publicProcedure,为它腾出命名空间,再基于它创建面向特定场景的具名 procedure 一并导出。这个模式叫做Base Procedures,是 tRPC 中实现"行为/代码复用"的关键模式——几乎所有应用都会用到它。

下面的示例定义了两个 Base Procedure:

  1. authedProcedure:断言用户已登录;
  2. 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,于是下游addMemberopts.ctx同时包含非空user与被查询的组织,opts.input也自动合并了organizationId与新加的email字段。

提示:文档也指出,这是一个简化示例;真实项目中你通常会组合使用 Headers、Context、Middleware 与 Metadata 来完成用户认证与授权,相关话题可参考 authorization.md。

官方示例中 Base Procedure 的实际落地

在仓库的examples/minimal示例里,可以看到这套模式的最小工程化版本。文件 examples/minimal/src/server/trpc.ts 只做一件事:初始化 tRPC 后端、导出可复用的routerpublicProcedure

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,同时将新增inputsmiddlewares分别追加到已有数组末尾。也就是说,在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-32600400请求/入参格式错误
UNAUTHORIZED-32001401未登录
FORBIDDEN-32003403已登录但无权限
NOT_FOUND-32004404目标资源不存在
CONFLICT-32009409状态冲突(如重复创建)
TOO_MANY_REQUESTS-32029429触发限流
INTERNAL_SERVER_ERROR-32603500未捕获的未知错误

抛出的TRPCError类定义在 error/TRPCError.ts:构造函数接收{ message?, code, cause? }code为必填;未知异常在中间件链中被捕获后会统一转换成INTERNAL_SERVER_ERRORTRPCError(见 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 个类型参数中分别抽取TContextTMetaTContextOverridesTInputOut,再套回ProcedureResolverOptions。代码中特意处理了两个边界:

  • 若当前 builder 尚未声明 input,则把 input 推断为unknown(而不是undefined),因为链上后续仍可能追加.input()
  • 若 input 是对象类型,会额外附带一个索引签名[keyAddedByInputCallFurtherDown: string]: unknown,以允许链尾继续新增输入字段。

仓库自带的行为测试在 procedureBuilder.test.ts:测试先用同一个模式构造authedProcedure,再断言authedProcedureHelperFn(参数类型为inferProcedureBuilderResolverOptions<typeof authedProcedure>)中opts.inputunknownopts.ctx.user为非空User,随后在addPostdeletePost等多个 mutation / query 中直接复用该辅助函数——正是文档描述场景的可执行验证。

附:Subscriptions 与继续深入

tRPC v11 中的订阅以 AsyncIterable / SSE 为主要形态,编写方式、重连与鉴权策略与 query/mutation 差异较大。仓库官方将其单独成篇,本文不展开,详见 subscriptions.md(对应@trpc/serverSubscriptionProcedure的定义见 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/serverexamples目录中对照阅读。

【免费下载链接】trpc🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 14:11:21

30分钟打通深度学习数学基础:跟着李宏毅教程走一遍

30分钟打通深度学习数学基础&#xff1a;跟着李宏毅教程走一遍 【免费下载链接】leedl-tutorial 《李宏毅深度学习教程》&#xff08;李宏毅老师推荐&#x1f44d;&#xff0c;苹果书&#x1f34e;&#xff09;&#xff0c;PDF下载地址&#xff1a;https://github.com/datawhal…

作者头像 李华
网站建设 2026/9/10 14:09:28

如何自建 DocuSeal 电子签名平台:三步完成本地部署

如何自建 DocuSeal 电子签名平台&#xff1a;三步完成本地部署 【免费下载链接】docuseal Open source DocuSign alternative. Create, fill, and sign digital documents ✍️ 项目地址: https://gitcode.com/GitHub_Trending/do/docuseal 需要收集的签名散落在邮件里&…

作者头像 李华
网站建设 2026/9/10 14:08:44

MySQL大表DDL操作风险与PT-OSC实战指南

1. 大表DDL操作的风险全景图 上周隔壁团队凌晨三点发来的求救电话还让我心有余悸——一次简单的ALTER TABLE操作&#xff0c;导致核心订单表锁死近4小时。这正是我们今天要深入探讨的问题&#xff1a;当你的MySQL表数据量突破千万级&#xff0c;任何DDL操作都如同在钢丝上跳舞。…

作者头像 李华
网站建设 2026/9/10 14:07:25

10分钟集成Tracy Profiler:用纳秒级性能分析定位游戏帧率卡顿

10分钟集成Tracy Profiler&#xff1a;用纳秒级性能分析定位游戏帧率卡顿 【免费下载链接】tracy Frame profiler 项目地址: https://gitcode.com/GitHub_Trending/tr/tracy 60FPS 的一帧只有 16ms&#xff0c;某几帧突然多花 3ms&#xff0c;帧率曲线上只是一根毛刺&am…

作者头像 李华