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.tsexport 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 中非常灵活的后端函数原语。它采用不可变 builderimmutable 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: () Promisevoid }().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实例它与router、middleware、mergeRouters、createCallerFactory一起构成返回的根对象。initTRPC本身在文件末尾以new TRPCBuilder()导出见同文件 L221每个后端建议只初始化一次随后通过模块导出共享t及其派生物。resolver 收到的opts里到底有什么resolver 的参数类型由 procedureBuilder.ts 中的ProcedureResolverOptions定义export interface ProcedureResolverOptionsTContext, _TMeta, TContextOverrides, TInputOut { ctx: SimplifyOverwriteTContext, TContextOverrides; // 上下文可能已被中间件覆盖/收窄 input: TInputOut extends UnsetMarker ? undefined : TInputOut; // 经输入校验后的入参 signal: AbortSignal | undefined; // 请求的 AbortSignal可用于取消 path: string; // 该 procedure 的完整调用路径 batchIndex?: number; // 批量请求中的调用序号批处理时存在 }也就是说上面goodbye中解构出的opts.ctx就是你在 Context 中声明的{ signGuestBook: () Promisevoid }并且类型在编译期被完整保留——这正是 tRPC端到端类型安全的根基之一。关于 Context 的完整讲解见 context.md。可复用 Base ProcedurepublicProcedure与extends模式tRPC 官方推荐的通用模式是把t.procedure重命名导出为publicProcedure为它腾出命名空间再基于它创建面向特定场景的具名 procedure 一并导出。这个模式叫做Base Procedures是 tRPC 中实现行为/代码复用的关键模式——几乎所有应用都会用到它。下面的示例定义了两个 Base ProcedureauthedProcedure断言用户已登录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.contextContext().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与publicProcedureimport { 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.tscreateInputMiddleware会先通过opts.getRawInput()拿到原始输入再交给 parser 解析若解析失败抛出的错误被统一包装为code: BAD_REQUEST的TRPCErrorcreateOutputMiddleware则先await next()拿到 resolver 的返回结果成功后才执行输出解析若输出校验失败抛出code: INTERNAL_SERVER_ERROR的TRPCErrormessage 为Output validation failed。而.input(z.object(...))中的 schema 之所以能兼容 zod 之外的众多校验库是因为 parser.ts 的getParseFn会按能力探测自动适配函数式 parser、parseAsynczod、validateSyncyup、createsuperstruct、assertarktype / 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_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.contextContext().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: inferProcedureBuilderResolverOptionstypeof 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 合并都会在inferProcedureBuilderResolverOptionstypeof 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参数类型为inferProcedureBuilderResolverOptionstypeof 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.mdt.router的完整能力与 router 合并context.md本文opts.ctx的来源与注入时机middlewares.md.use()中间件的完整 API 与组合方式validators.md.input()/.output()及各类校验库适配metadata.md.meta()与路由层元数据authorization.md把 Base Procedure 用于真实鉴权体系error-handling.mdTRPCError的错误码与格式化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),仅供参考