通过逐步部署 tRPC 在编译时捕获 API 合同偏差
tRPC会将重命名的后端字段转化为TypeScript编译错误。如何在REST之外逐个端点逐步引入它,以及何时它并非合适的工具。
来源: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**
想象这样一个借贷平台:后端工程师将贷款申请响应中的applicantName改名为applicantFullName。API、后端测试以及拉取请求都已完成更新并获得通过,但两天后贷款汇总页面却显示空白的姓名:没有异常提示,也没有控制台警告,只有空字符串,因为前端仍在读取一个已不存在的键。该平台的前后端均使用TypeScript编写,但这些类型信息并未跨越网络边界传递。下文将介绍tRPC如何填补这一空白、如何在REST之外逐步采用它,以及它在哪些情况下不适用。
tRPC是什么,又不是什么
tRPC并非一种网络传输协议,也不会在传输层上取代REST或GraphQL。请求仍然是普通的HTTP调用。它所增加的是一个共享类型层:服务器函数的返回类型由TypeScript自动推断,并直接传递给客户端,无需任何模式文件或代码生成步骤。
一个有助于理解的概念是函数调用恰好在服务器端执行。调用方与被调用方之间的契约由TypeScript编译器定义,而非可能已更新或未更新的独立规范文档。当服务器更改函数的返回值时,所有依赖旧数据结构的客户端调用都会编译失败。错误会在编辑器中读取缺失字段的那行立即显现,而不会在数天后的实际运行环境中才被发现。
无类型API层的代价
在上述场景中,前端服务层的结构大致如下:每个方法都会获取 JSON 数据并以 any 类型返回,而组件则直接信任该结果并读取其中的字段。
// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't
团队统计出共有 47 个方法的返回类型为 any。每一个方法都存在这样的可能性:通过重命名字段、调整嵌套对象结构或更改枚举值,可以让代码在编译时正常通过,但在运行时——即用户在查看应用程序状态时——才会出现问题。对于金融产品而言,这绝非仅仅是表面上的缺陷:向客户展示错误或缺失的数据可能会引发合规问题。
采用“绞杀者模式”进行迁移
一次性重写所有端点存在风险。更安全的做法是采用“绞杀者无花果”策略:保持现有的 REST 端点不变,在其旁边引入 tRPC,然后逐个迁移端点。每个被迁移的端点都能立即获得类型安全保障,而尚未迁移的端点则无需做任何更改。
定义路由器
服务器端从 initTRPC.create() 开始,该方法会返回一个通常命名为 t 的构建器对象。路由可以嵌套,因此 loans 子路由用于整合所有贷款相关的处理流程。请注意代码片段中的三点:首先,每个处理流程都使用 Zod 模式来定义输入参数,这样在处理器执行之前,格式错误的 ID 或未知状态就会被拒绝;同时该模式也为处理器提供了类型化的 input 参数。其次,读取操作使用 query,写入操作则使用 mutation。最后,无需手动指定返回类型——TypeScript 会根据数据库调用的结果自动推断。最后一行仅导出了路由的类型,这正是客户端所使用的部分。
// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;
如果复制此代码需要修正的一个细节是:处理函数会抛出 TRPCError 异常,该异常也需要从 @trpc/server 中导入,而这里的导入语句中缺少这一项。
创建客户端
在前端,客户端是通过 AppRouter 类型构建的。由于使用了 import type,因此服务器代码不会被包含在浏览器打包文件中,只有类型信息会被共享。httpBatchLink 会将同一时间间隔内发起的多个请求合并为一个HTTP请求,其 headers 回调会在每个请求时执行,这是添加承载令牌的便捷位置。
// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});
工厂函数名反映了所使用的tRPC版本:createTRPCProxyClient对应v10 API,而较新版本则使用createTRPCClient。请查阅当前文档以确认您安装的版本。共享该类型还意味着客户端能够导入它,通常是通过单仓库结构或共享工作区包来实现。
调用过程
现在的调用方式类似于对本地对象的方法调用。由于结果具有完整的类型信息,因此若尝试访问服务器不再返回的字段,将会导致编译错误,而不会出现隐式的undefined值。
// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error
回应团队的反对意见
预计会遇到反对声音。通常会有三种反对意见,每种都需要给出明确的答复。
- 为何不选择带代码生成的 OpenAPI?这是最有力的反对意见,因为通过生成的方式确实可以获得类型信息。但其代价是必须维护架构规范、定期运行生成步骤、将生成的文件提交到版本控制,并且还要确保所有内容保持同步。而使用 tRPC 时,服务器代码本身就是唯一的真实数据源。如果您更倾向于 OpenAPI 方式,可查看如何从 NestJS Swagger 生成带类型的 Next.js 客户端。
- 那些未用 TypeScript 编写的客户端怎么办?在这种情况下,可以使用一些内部的 Python 工具来调用 API。这些工具的接口仍然采用 REST 方式。由于两种风格可以共存,因此逐步迁移十分简单。
收益显现之时
大约六周后,贷款申请状态机需要进行结构调整。原本单一的状态字段被改为嵌套对象,用以同时记录审核阶段和审批结果:
// Before
type LoanApplication = {
status: 'PENDING' | 'APPROVED' | 'REJECTED';
}
// After
type LoanApplication = {
applicationStatus: {
stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
};
}
在旧架构下,任何读取application.status的组件都会在编译时通过,但在运行时出现故障,唯一的解决办法就是在整个代码库中搜索后再寄希望于运气。而使用tRPC后,只需更改函数的返回类型,就能立即识别出8个前端调用位置。所有问题都在一个下午内得到解决,而且涉及多个系统的改动也并未引发任何运行时错误。此后,讨论的重点从质疑这次迁移转向了下一个要迁移的接口是什么。
六个月后的成果
- API层中使用
any类型作为返回值的接口从47个减少到3个。 - 剩下的3个是第三方webhook的负载数据,它们没有定义固定的类型;这类数据会通过经过验证的Zod模式进行解析,而非被盲目信任。
被移除的44种any类型代表着那些从未有机会出现的潜在错误。如需了解消除剩余问题的方法,请参阅六种用于替代any的类型安全方案。
tRPC不适用的场景
只有当连接的双方都使用TypeScript时,tRPC才能发挥最大作用。对于多语言后端或被第三方使用的公共API,则需要像OpenAPI或GraphQL这样的明确且与具体语言无关的契约格式。
还需记住,该保证仅针对输出结果在编译时生效。Zod会在运行时验证输入,但推断出的返回类型不会在运行时进行任何检查,而像上述路由器那样直接返回原始数据库记录会将所有列都发送给客户端。对于敏感数据,应选择或映射那些真正需要公开的字段。
关键要点
- 在双方都使用TypeScript并不意味着它们之间存在类型化的契约;tRPC无需模式定义或代码生成步骤即可填补这一空白。
- 在保留REST接口的同时逐个端点进行迁移,这样非TypeScript客户端仍能正常使用,且每一步改动都足够小,便于审查。
- 最大的优势体现在代码重构时,因为编译器会为你列出所有受影响的调用位置。