tRPC

tRPC将TypeScript router转换为类型化客户端契约;掌握运行时边界、验证、上下文、中间件与部署陷阱。

难度 进阶 时长 标准深度约15分钟
版本 Node 24
what

tRPC根据TypeScript服务端router构建类型化调用接口。客户端通过类型推断得到procedure路径、输入与输出,不需要另写API模式或生成客户端。

when

当同一团队控制TypeScript客户端与服务端,并能在构建时共享router类型时,可以使用tRPC。若独立客户端或非TypeScript客户端需要调用API,应优先考虑语言无关的契约。

how

在运行时验证所有输入,从请求上下文推导身份,对目标对象执行授权,并且只公开有意设计的输出。批处理、序列化与部署都属于TypeScript无法证明的运行时契约。

是什么,为什么存在

tRPC是一种TypeScript 远程过程调用(remote procedure call,RPC) 接口实现。服务端把具名procedure组合成router,并且只导出router的TypeScript类型。tRPC客户端利用该类型,为procedure路径、输入与结果提供调用式补全和静态检查。

它的核心收益是让私有TypeScript API只有一个事实来源。当procedure的输入从{ id: string }改成{ id: string; revision: number }时,使用同一router类型编译的调用方会收到类型错误。团队不必再同步第二份OpenAPI文档或生成的SDK。

这项保证的范围比表面上窄。请求经过网络时,TypeScript类型早已消失,所以tRPC仍需在运行时解析输入。传输可能失败,已经部署的旧客户端可能调用新服务端,而类型完全正确的调用方仍可能无权读取某一行数据。

tRPC适合monorepo、全栈TypeScript应用,以及客户端与服务端一起发布的内部服务。如果移动应用、合作方集成、Webhook或多语言服务也要调用同一API,只提供tRPC往往不合适。这些调用方通常需要语言无关且可独立演进的接口描述。

tRPC不规定数据库或应用架构。procedure通常应把传输输入适配到应用服务,而不是容纳全部查询与业务规则。保持服务层独立,还能让HTTP handler、后台任务、测试与服务端调用复用同一行为。

本页使用Node 24.14.0、TypeScript 6.0.3、@trpc/server 11.18.0和Zod 4.5.4验证。示例通过服务端调用器保持单文件可运行;同一个router也可以由HTTP adapter暴露。

工作原理

tRPC服务端从initTRPC开始,可以为它指定上下文类型。初始化结果包含router、procedure与middleware构建器。 tRPC router 是一棵具名树, tRPC procedure 则是树中一个可调用的叶节点。

每个procedure都会构建一条有序管线,其中包含解析器middleware和用户middleware。输入解析器产生经过验证的值,middleware可以拒绝调用,也可以向后传递类型已收窄的上下文,query、mutation或subscription resolver则执行操作。管线返回时,输出解析器检查结果;抛出的TRPCError携带稳定的tRPC错误码。

客户端proxy不会在运行时下载或检查服务端router。它的泛型AppRouter类型只存在于编译阶段;访问client.order.byId.query()等属性时,proxy会构造操作路径,并通过已配置的link发送数据。服务端adapter再根据真实router解析该路径。

Router与procedure

Router把相关能力组成order.byId、order.rename等命名空间。嵌套router只负责组织结构,不构成授权边界。通常把根router的typeof结果导出为AppRouter,客户端使用类型导入,以免服务端实现代码进入客户端bundle。

Query表示读取,mutation表示写入或其他副作用。这些标签会影响客户端集成与缓存行为,但不会自动保证resolver只读,也不会自动创建事务。相关语义仍需由应用服务和数据库强制执行。

Procedure输入可以使用Zod或其他受支持的Standard Schema验证器。解析器同时决定传给resolver的运行时值和推断出的输入类型。因此,转换与默认值属于procedure契约,而不只是编辑器提示。

上下文与中间件

tRPC上下文(tRPC context) 是调用时提供的应用状态,通常包括已认证主体、数据库访问对象、请求元数据和跟踪设施。HTTP adapter一般根据传入请求创建上下文。同一HTTP请求中的批量操作会共享请求上下文,因此必须有意隔离每个操作的可变状态。

Middleware包裹procedure管线的后续部分。认证middleware可以拒绝不存在的主体,并向后传递主体非空的上下文。授权通常还需要更多工作:加载请求的资源后,代码必须证明该主体可以对该资源执行当前操作。

先构建publicProcedure、protectedProcedure等可复用基础procedure,再为租户或角色规则扩展它们。不要依赖resolver必须记得检查的全局标志。只有每条敏感路由确实继承受保护构建器时,protected这个名字才有意义。

静态契约与运行时契约

类型推断(type inference) 把当前客户端构建连接到router声明。运行时解析器则防止真实进程接收畸形、过期或恶意输入。两者都重要,也都不能取代认证、授权、数据库约束或业务不变量。

输出解析器会在结果离开procedure管线前检查resolver返回值。它既能发现内部数据边界上的漂移,也能建立白名单式响应形状。它仍应配合显式结果映射,因为先意外返回数据库对象、再交给解析器处理,会让代码意图难以审查。

错误也要跨越边界。客户端决策应使用BAD_REQUEST、UNAUTHORIZED、FORBIDDEN、NOT_FOUND等稳定错误码,内部原因则记录在服务端。即使TypeScript错误类型中存在堆栈、SQL消息或秘密,也不能直接把它们暴露出去。

调用与传输

HTTP客户端通过httpBatchLink等link发送操作,adapter接收操作并创建上下文。服务端调用器在进程内调用同一router,需要直接提供上下文。两条路径都会执行procedure解析与middleware,但只有HTTP路径会覆盖header、序列化、请求限制、proxy与网络故障。

批处理可以把多个操作放入一次HTTP交换。它在合适负载下降低传输开销,但每个procedure仍是独立操作。批次不是数据库事务,不会共享一次授权决策,也可能同时包含成功与失败结果。

普通JSON无法保留Date、Map、Set或自定义类的身份。可以把procedure边界设计为JSON形状,在验证器中转换字符串,或者在客户端与服务端一致配置数据转换器。无论选择哪种方案,它都会成为部署兼容性的一部分。

示例

四个示例依次展示router与输入解析器、授权、输出验证,以及JSON形状的时间值。显示的输出均来自本地工具链与上文所列的确切版本。

调用经过验证的router

第一个router只有一个query。createCaller()让示例无需HTTP服务端也能运行,Zod仍会通过正常的procedure管线拒绝空ID。

basic-router.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
const products = [
  { id: "p1", name: "Mechanical keyboard", stock: 4 },
  { id: "p2", name: "USB-C dock", stock: 0 },
];

const appRouter = t.router({
  productById: t.procedure
    .input(z.object({ id: z.string().min(1) }))
    .query(({ input }) => {
      const product = products.find((item) => item.id === input.id);
      if (!product) throw new TRPCError({ code: "NOT_FOUND" });
      return product;
    }),
});

async function main() {
  const caller = appRouter.createCaller({});
  console.log(JSON.stringify(await caller.productById({ id: "p1" })));
  try {
    await caller.productById({ id: "" });
  } catch (error) {
    if (error instanceof TRPCError) {
      console.log(error.code);
    }
  }
}

main();

export type AppRouter = typeof appRouter;
{"id":"p1","name":"Mechanical keyboard","stock":4}
BAD_REQUEST

无效调用仍能通过编译,是因为它的值依然是字符串;运行时解析器会强制执行最小长度。向经过类型检查的调用器传入数字字面量会触发TypeScript错误,但真实端点仍必须拒绝该值,因为网络客户端可以忽略类型,也可能根本没有类型信息。

导出的AppRouter是客户端以类型形式导入的契约。它包含推断出的procedure结构,不会在运行时包含products数组。HTTP部署还需要单独用adapter挂载appRouter。

收窄上下文并对对象授权

Middleware证明viewer存在,因此后续代码看到的ctx.viewer非空。Resolver随后执行对象级授权;完成认证并不能证明调用方拥有o1。

protected-procedure.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";

type Context = { viewer: { id: string } | null };
const t = initTRPC.context<Context>().create();
const orders = [{ id: "o1", ownerId: "u1", label: "Desk lamp" }];

const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.viewer) throw new TRPCError({ code: "UNAUTHORIZED" });
  return next({ ctx: { viewer: ctx.viewer } });
});

const appRouter = t.router({
  renameOrder: protectedProcedure
    .input(z.object({ orderId: z.string(), label: z.string().min(1) }))
    .mutation(({ ctx, input }) => {
      const order = orders.find((item) => item.id === input.orderId);
      if (!order || order.ownerId !== ctx.viewer.id) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
      order.label = input.label;
      return { id: order.id, label: order.label };
    }),
});

async function main() {
  const alice = appRouter.createCaller({ viewer: { id: "u1" } });
  console.log(JSON.stringify(
    await alice.renameOrder({ orderId: "o1", label: "Reading lamp" }),
  ));
  const bob = appRouter.createCaller({ viewer: { id: "u2" } });
  try {
    await bob.renameOrder({ orderId: "o1", label: "Mine" });
  } catch (error) {
    if (error instanceof TRPCError) console.log(error.code);
  }
}

main();
{"id":"o1","label":"Reading lamp"}
FORBIDDEN

调用方无法选择ownerId,身份来自可信上下文。接入数据库后,应优先使用限定租户或所有者的查询或条件更新,让授权谓词始终附着在数据操作上。

无论订单不存在还是属于其他人,这里都返回FORBIDDEN,从而避免暴露哪些ID确实存在。服务也可以选择NOT_FOUND,但这种信息披露策略必须经过有意设计,并保持一致。

约束输出

这里的Zod输出对象充当白名单,默认对象行为会移除额外的passwordHash。第二个resolver使用缺少字段的无类型外部数据来模拟故障,结果会在返回前失败。

output-validation.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
const publicUser = z.object({
  id: z.string(),
  displayName: z.string(),
});

const appRouter = t.router({
  user: t.procedure.output(publicUser).query(() => ({
    id: "u1",
    displayName: "Ari",
    passwordHash: "do-not-return",
  })),
  brokenUser: t.procedure
    .output(publicUser)
    .query(() => JSON.parse('{"id":"u2"}')),
});

async function main() {
  const caller = appRouter.createCaller({});
  console.log(JSON.stringify(await caller.user()));
  try {
    await caller.brokenUser();
  } catch (error) {
    if (error instanceof TRPCError) {
      console.log(`${error.code}: ${error.message}`);
    }
  }
}

main();
{"id":"u1","displayName":"Ari"}
INTERNAL_SERVER_ERROR: Output validation failed

如果结果来自无类型SDK、原始SQL或渐进式迁移,输出验证尤其有用。还应把敏感记录映射为公开结果对象,再用解析器对漂移做第二层检查。

外部客户端应收到稳定的内部错误形状,而不是验证器的完整诊断信息。详细原因应进入服务端日志,并与请求或trace标识关联。

转换传输表示

这个mutation接收ISO 8601字符串,验证后将其转换为resolver使用的Date。应用代码可以得到更丰富的值,而传输表示仍然明确。

wire-input.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
const scheduleInput = z.object({
  name: z.string().min(1),
  startsAt: z.iso.datetime().transform((value) => new Date(value)),
});

const appRouter = t.router({
  schedule: t.procedure
    .input(scheduleInput)
    .mutation(({ input }) => ({
      name: input.name,
      year: input.startsAt.getUTCFullYear(),
    })),
});

async function main() {
  const caller = appRouter.createCaller({});
  const result = await caller.schedule({
    name: "launch",
    startsAt: "2026-10-03T09:30:00.000Z",
  });
  console.log(JSON.stringify(result));
}

main();
{"name":"launch","year":2026}

验证器的输入类型是字符串,解析后的输出则包含Date。传输数据与resolver数据不同时,这项区分很有用。若改用已配置的transformer传输日期,应在滚动部署期间验证两端使用同一transformer。

时区含义仍是业务决策。强制要求偏移量或Z可以避免两台服务器用不同方式解释本地墙钟时间,但排期规则还可能需要具名时区和夏令时策略。

陷阱

深入 类型图不是传输模式

类型图不是传输模式

AppRouter会保留procedure路径,以及构建器链产生的输入与输出类型。TypeScript可以跨package边界跟踪这张图,为客户端提供精确补全。但编译完成后,服务端不会把类型图发送给调用方,其他语言也无法据此发现接口契约。

因此,在共享TypeScript代码库中,tRPC可以省去代码生成,但它不会自动成为公开API描述。文档、示例、变更日志、错误语义和兼容策略仍需主动发布。如果独立调用方需要机器可读的接口发现能力,schema-first协议可能是更稳固的边界。

输入验证器更接近传输模式,但它本身不会描述传输header、认证、限流、重试安全或全部错误结果。Zod transform还会让可接受的输入类型不同于resolver收到的解析值。修改procedure时,应同时审查转换的两侧。

输出推断会跟随resolver成功返回的类型。如果没有输出解析器,意外出现的额外属性可能进入推断出的客户端契约,并被序列化。显式数据传输对象或输出验证器可以防止数据库记录形状悄悄定义API。

结构类型还会形成另一种审查陷阱。两个ID即使分别表示用户和订单,只要都表示为string,就可以互相赋值。品牌化标识或具有语义名称的schema能改善静态审查,但服务端仍须验证对象存在性与所有权。

共享package与构建边界

共享契约package应导出类型和有意共享的schema,不能在导入时产生服务端初始化副作用。数据库客户端、环境变量读取和含秘密配置应留在仅服务端模块中。还要验证package export map与bundler条件不会通过便利的barrel文件暴露这些模块。

仅包含类型的循环导入仍会让项目难以构建和重构。使用小型router聚合模块和单向依赖,可以保持各功能router相互独立。应用服务不应反过来导入调用自己的传输router。

静态类型检查只能证明源码修订之间的关系,不能证明部署拓扑。应记录测试实际覆盖的客户端与服务端版本。Monorepo构建通过,不代表昨天的浏览器bundle一定能调用今天的服务端。

运行时执行与故障边界

createCaller()适合可信的进程内调用和聚焦示例。它不经过HTTP就能执行router procedure,但需要调用方自行提供上下文。不要根据未经验证的函数参数构造该上下文,再误认为它等同于经过adapter认证的状态。

不要把通过调用器调用另一个procedure当成procedure之间的普通代码复用。这会重复面向传输的管线,还可能掩盖事务所有权、日志记录和错误语义。应把共享业务行为提取为服务函数,再让两个procedure带着明确的授权与事务上下文调用它。

HTTP adapter会引入直接调用无法复现的故障。请求体可能截断,header可能缺失,proxy可能超时,mutation提交后响应也可能丢失。只要这些结果会影响重试或用户可见错误,集成测试就需要覆盖真实adapter。

上下文生命周期与批处理

上下文通常应按传入请求创建。如果一个批次共享一份上下文,不可变身份与请求级服务适合放在其中,可变临时数据则可能让原本独立的procedure相互耦合。进程全局上下文更危险,因为凭据、loader或事务handle可能泄漏到其他用户。

批次大小与请求大小都需要明确上限。一个HTTP请求仍可包含很多昂贵操作,resolver内部并发还会放大数据库压力。声称批处理更快之前先测量真实负载,再限制服务端接受的工作量。

部分失败需要客户端策略。如果批次中一个query验证失败,无关操作仍可能返回结果;如果传输失败,客户端可能无法判断哪些mutation已提交。对于确实需要重放的写入,应设计幂等机制,而不是不加区分地重试整个批次。

序列化与错误

序列化(serialization) 决定哪些值能通过传输以及如何通过。普通JSON的数据模型有意保持精简。Transformer可以保留更多类型,但必须在两端对称配置,其编码格式也会成为兼容性与安全边界。

错误码应保持稳定;除非明确对消息做版本管理,否则错误消息不应成为契约。把已知领域故障映射到有意选择的错误码,用关联元数据记录意外原因,并返回有界公开形状。前端应根据稳定错误码分支,而不是匹配英文消息文本。

最后,需要双向测试边界。发送任何TypeScript客户端都不会构造的原始畸形请求,再让虚假依赖返回畸形数据,以触发输出解析器。这些测试覆盖的正是端到端类型推断无法消除的缺口。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

下一篇 RESTful API设计 GraphQL gRPC Backend security 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?