在 tRPC 中推断客户端类型:用inferRouterInputs/inferProcedureInput等工具类型打通端到端类型安全
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
本指南围绕 www/docs/client/vanilla/infer-types.md 讲解如何在 vanilla(原生 TS)客户端中复用服务端AppRouter的类型信息:无需在客户端重新声明任何类型,即可自动拿到路由、procedure 的输入/输出类型以及带类型的TRPCClientError。读完本文你将掌握inferRouterInputs、inferRouterOutputs、inferProcedureInput、inferProcedureOutput、inferSubscriptionInput、inferSubscriptionOutput六个官方推断工具类型的正确用法,并理解它们在 tRPC 服务端源码中的实现原理。
为什么需要“推断类型”:客户端复用服务端AppRouter
tRPC 的核心卖点是端到端类型安全:服务端用initTRPC构建一个路由树(router),客户端通过同一份类型定义获得带类型的调用链。为了让客户端也能“按路径”精确访问 API 的类型,常见做法是在服务端文件里导出路由的类型:
// server.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); const appRouter = t.router({ post: t.router({ list: t.procedure.query(() => { // imaginary db call return [{ id: 1, title: 'tRPC is the best!' }]; }), byId: t.procedure.input(z.string()).query((opts) => { // imaginary db call return { id: 1, title: 'tRPC is the best!' }; }), create: t .procedure .input(z.object({ title: z.string(), text: z.string() })) .mutation((opts) => { // imaginary db call return { id: 1, ...opts.input }; }), onPostAdd: t .procedure .input(z.object({ authorId: z.string() })) .subscription(async function* ({ input }) { // imaginary event source yield { id: 1, title: 'tRPC is the best!', authorId: input.authorId, }; }), }), }); export type AppRouter = typeof appRouter;导出AppRouter之后,客户端往往需要“按路径切片”地访问其中某个 procedure 的输入或输出类型——这正是 tRPC 提供推断工具类型的目的。从 packages/server/src/@trpc/server/index.ts 可以看到,@trpc/server统一对外导出以下六个推断工具类型:
inferRouterInputs<TRouter>—— 按路由树结构推断所有 procedure 的输入类型inferRouterOutputs<TRouter>—— 按路由树结构推断所有 procedure 的输出类型inferProcedureInput<TProcedure>—— 推断单个 procedure 的输入类型inferProcedureOutput<TProcedure>—— 推断单个 procedure 的输出类型inferSubscriptionInput<TProcedure>—— 推断单个订阅 procedure 的输入类型inferSubscriptionOutput<TProcedure>—— 推断单个订阅 procedure 发出的数据(yield)类型
它们都是纯类型层(type-level)的工具,只存在于编译期,运行时不产生任何代码,因此可以放心地import type引入。
推断整棵路由树的输入与输出类型
沿用上文AppRouter,先推断“整棵路由树按路径组织的输入/输出映射”:
// client.ts import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from './server'; type RouterInput = inferRouterInputs<AppRouter>; type RouterOutput = inferRouterOutputs<AppRouter>; type PostCreateInput = RouterInput['post']['create']; // ^? 推断为 { title: string; text: string } type PostCreateOutput = RouterOutput['post']['create']; // ^? 推断为 { id: number; title: string; text: string }inferRouterInputs<AppRouter>得到的是一个与AppRouter同构的嵌套对象类型:RouterInput['post']对应post子路由,RouterInput['post']['create']精确命中post.create这条 mutation 的入参结构体。- 同理,
inferRouterOutputs<AppRouter>按相同路径给出各 procedure 的返回类型。
也就是说,create的输入是{ title: string; text: string }(由 zod schema 推导),输出则是 resolver 返回的{ id: number; title: string; text: string }。你完全可以把这个嵌套类型交给辅助函数、表单组件或状态管理模块使用,避免在客户端重复手写同名结构。
从源码实现看,inferRouterInputs/inferRouterOutputs定义在 packages/server/src/unstable-core-do-not-import/clientish/inference.ts,它们内部借助一个叫GetInferenceHelpers的递归映射类型:
export type GetInferenceHelpers< TType extends 'input' | 'output', TRoot extends AnyClientTypes, TRecord extends RouterRecord, > = { [TKey in keyof TRecord]: TRecord[TKey] extends infer $Value ? $Value extends AnyProcedure ? TType extends 'input' ? inferProcedureInput<$Value> : inferTransformedProcedureOutput<TRoot, $Value> : $Value extends RouterRecord ? GetInferenceHelpers<TType, TRoot, $Value> // 递归进入嵌套子路由 : never : never; };可以看到两条关键规则:凡是AnyProcedure的叶子节点按类型求 input/output;凡是嵌套的RouterRecord就递归展开,因此无论路由树嵌套多深,RouterInput/RouterOutput都保持与AppRouter相同的形状。
推断单个 procedure 的输入与输出类型
如果你手上已经有某个具体 procedure(例如通过AppRouter['post']['byId']取到了它),可以直接用inferProcedureInput/inferProcedureOutput精确推断,而不必维护整棵 Router 类型的中间变量:
// client.ts import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server'; import type { AppRouter } from './server'; type PostByIdInput = inferProcedureInput<AppRouter['post']['byId']>; // ^? 推断为 string(byId 的输入是 z.string()) type PostByIdOutput = inferProcedureOutput<AppRouter['post']['byId']>; // ^? 推断为 { id: number; title: string }这两个工具类型的实现位于 packages/server/src/unstable-core-do-not-import/procedure.ts。它们的核心是提取 procedure 的_def['$types'](即 procedure 定义中保存的类型元数据):
export type inferProcedureParams<TProcedure> = TProcedure extends AnyProcedure ? TProcedure['_def'] : never; export type inferProcedureOutput<TProcedure> = inferProcedureParams<TProcedure>['$types']['output']; export type inferProcedureInput<TProcedure extends AnyProcedure> = undefined extends inferProcedureParams<TProcedure>['$types']['input'] ? void | inferProcedureParams<TProcedure>['$types']['input'] : inferProcedureParams<TProcedure>['$types']['input'];这段源码还揭示了一个容易被忽略的语义细节:当 procedure 没有声明.input()时,它的输入类型被视为undefined,推断结果会并入void。例如上文的post.list没有入参,则inferProcedureInput<AppRouter['post']['list']>会得到void,这与调用端post.list.query()不传参的使用方式一致。值得注意的是inferRouterInputs/inferProcedureInput只处理运行时经校验后的输入,服务端对输入做过的 validator 逻辑(如.refine等)不会体现在类型上,这是推断类型的边界所在。
订阅(subscription)的输入与输出类型推断
tRPC 的订阅与普通 query/mutation 不同:procedure 的 resolver 不是返回单个值,而是通过async function*持续yield数据。因此 tRPC 为订阅提供了专用的一对工具类型:
// client.ts import type { inferSubscriptionInput, inferSubscriptionOutput, } from '@trpc/server'; import type { AppRouter } from './server'; type OnPostAddInput = inferSubscriptionInput<AppRouter['post']['onPostAdd']>; // ^? 推断为 { authorId: string }(订阅建立时携带的入参) type OnPostAddOutput = inferSubscriptionOutput<AppRouter['post']['onPostAdd']>; // ^? 推断为 { id: number; title: string; authorId: string }(每次 yield 的数据)inferSubscriptionInput<TProcedure>本质复用inferProcedureInput(订阅建立时的入参),而inferSubscriptionOutput<TProcedure>则针对新版异步生成器式订阅(async function*)额外做了一层解包:取 async iterable 每次yield的类型,而非整个生成器的类型。见 packages/server/src/unstable-core-do-not-import/procedure.ts:
export type inferSubscriptionInput<TProcedure extends AnySubscriptionProcedure> = inferProcedureInput<TProcedure>; export type inferSubscriptionOutput<TProcedure extends AnySubscriptionProcedure> = TProcedure extends LegacyObservableSubscriptionProcedure<any> ? inferProcedureOutput<TProcedure> : inferAsyncIterableYield<inferProcedureOutput<TProcedure>>;inferSubscriptionOutput内部用inferAsyncIterableYield把AsyncGenerator的产出值解出来——这正是订阅客户端事件回调里拿到的“每条数据”的类型。如果你把inferProcedureOutput用在订阅 procedure 上,得到的将是不含解包的生成器类型,因此订阅请务必使用inferSubscriptionOutput。对于订阅的实践细节(如何在客户端建立连接、监听数据与关闭),可继续阅读 vanilla 客户端文档 aborting-procedures。
推断带路由形状的TRPCClientError错误类型
除数据本身外,客户端错误处理也需要类型。TRPCClientError可以携带泛型(router 或单个 procedure),使cause.data具备服务端错误结构中定义的类型。tRPC 客户端还在 packages/client/src/TRPCClientError.ts 中提供了配套的isTRPCClientError类型守卫,用于把unknown收窄为TRPCClientError:
// trpc.ts —— 先建立带类型的客户端 import { createTRPCClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from './server'; export const trpc = createTRPCClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000/api/trpc', }), ], });// client.ts import { TRPCClientError } from '@trpc/client'; import type { AppRouter } from './server'; import { trpc } from './trpc'; export function isTRPCClientError( cause: unknown, ): cause is TRPCClientError<AppRouter> { return cause instanceof TRPCClientError; } async function main() { try { await trpc.post.byId.query('1'); } catch (cause) { if (isTRPCClientError(cause)) { // `cause` 现在被收窄为 AppRouter 对应的 TRPCClientError console.log('data', cause.data); // data 中的字段(如 httpStatus、code、path)均带类型提示 } else { // 非 tRPC 错误(网络错误、非 JSON 响应等)走这里 } } } main();要点拆解:
createTRPCClient<AppRouter>(...)让trpc上每个方法调用都带上类型;错误对象在抛出时仍是unknown,因此需要isTRPCClientError守卫收窄。cause instanceof TRPCClientError是运行时检查,返回的cause is TRPCClientError<AppRouter>谓词让 TypeScript 在if分支内自动获得强类型。TRPCClientError<AppRouter>内部通过inferErrorShape结合服务端配置推导cause.data的具体结构(code、httpStatus、path等),见 TRPCClientError.ts 对错误形状泛型的约束。若把泛型参数换成单个 procedure,还能让错误对象与特定调用点对齐。
源码佐证:这些工具类型从哪里来、如何被引用
- 导出入口:六个推断类型统一从
@trpc/server导出(packages/server/src/@trpc/server/index.ts)。旧版本中inferProcedureInput/inferProcedureOutput曾从@trpc/server/shared或@trpc/server的子路径导出,现在这些位置已被标记@deprecated(见 packages/server/src/shared.ts),一律改为从@trpc/server直接导入。 - 类型语义:
AnyProcedure覆盖 query/mutation/subscription(procedure.ts),因此inferProcedureInput/inferProcedureOutput对三种 procedure 通用,订阅另有专属包装。 - 序列化边角:
inferRouterOutputs走的是inferTransformedProcedureOutput(inference.ts):当未启用数据转换器(transformer为false,默认情况)时会对输出做一次Serialize处理,模拟数据经过 JSON 序列化传输后的形态;该内部类型也随@trpc/server导出但属于低层 API,日常使用推荐上面六个官方类型。 - 服务端调用复用:同样的推断体系也被用于服务端直调(server-side call)场景,相关示例可参考 server-side-calls.md;React 版客户端存在完全同构的一篇文档 www/docs/client/react/infer-types.md,若你在 React 中做 RSC/SSG 数据预取,可对照阅读。
小结与实践建议
- 推断类型全部来自服务端
export type AppRouter = typeof appRouter,客户端零重复定义即可获得路由树级或单 procedure 级的类型信息;新增、删除路由或修改入参后,客户端类型自动同步,这就是 tRPC“类型即契约”的落地方式。 - 需要“按路径取一段”时用
inferRouterInputs<AppRouter>/inferRouterOutputs<AppRouter>(支持任意深度的嵌套子路由);已持有具体 procedure 引用时用inferProcedureInput/inferProcedureOutput;订阅 procedure 请使用inferSubscriptionInput/inferSubscriptionOutput,后者会自动解包 async generator 的 yield 类型。 - 错误处理用
TRPCClientError<AppRouter>+isTRPCClientError守卫,即可获得随服务端错误形状同步的强类型cause.data。 - 这些类型均为编译期结构,运行时不产生额外开销;官方文档的完整示例即当前仓库中的 infer-types.md,vanilla 客户端的初始化可参考 setup.mdx,整体使用模式见 overview.md。
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考