本节目标:看清「契约漂移」是怎么发生的;掌握 tRPC 的 router / procedure / context / middleware 四个核心概念;学会用 Zod 约束输入并让返回类型自动流向客户端;并清楚 tRPC 的能力边界,知道什么场景不该用它。
16.1 tRPC 端到端类型安全
前五章我们把服务端从路由、中间件、依赖注入一路铺到数据库、缓存和队列。但如果你观察过一个真实的前后端分离项目,会发现最频繁的事故既不在数据库也不在算法,而在接口契约:服务端把 userName 改成了 username,前端仍然读 userName,undefined 一路渲染成空白页,而 tsc 一句话都没说。
本章是全书的收口章节:前三节分别讲三种把契约「固化成类型」的路线——tRPC 走共享类型源,OpenAPI / GraphQL 走代码生成,版本演进讲契约变了以后怎么办。它们解决的是同一个问题,只是代价和适用面不同。
16.1.1 契约漂移:类型为什么守不住边界
先看一个几乎所有团队都写过的手写接口层:
// server/routes/user.ts —— 服务端
export async function getUser(id: string) {
const row = await db.user.findUniqueOrThrow({ where: { id } })
return { id: row.id, userName: row.name, email: row.email }
}
// web/api/user.ts —— 前端
interface User {
id: string
userName: string
email: string
}
const res = await fetch(`/api/users/${id}`)
const user: User = await res.json()
这段代码的破绽在最后一行:res.json() 的返回类型是 Promise<any>,把 any 赋给 User 永远不会报错。也就是说那个 interface User 不是契约,只是注释。服务端改了字段名,fetch 依然返回 200,user.userName 求值为 undefined,直到用户看到空白页才被发现。
要真正守住边界,只有一条路:让客户端拿到的类型不是人写的,而是从服务端实现里推导出来的。三种路线对比如下:
| 路线 | 类型来源 | 是否需要写接口声明 | 消费者范围 |
|---|---|---|---|
| 手写 interface | 人 | 要,且会漂移 | 任意 |
| OpenAPI / GraphQL codegen | schema 文件 | 要(写 schema) | 任意语言 |
| tRPC | 服务端实现本身 | 不要 | 仅 TypeScript |
tRPC 的取舍极其鲜明:牺牲跨语言能力,换来零接口声明与零漂移。下一节会讲另外两条路线,本节先把 tRPC 走通。
16.1.2 四个核心概念
在写代码之前,先把术语对齐。tRPC 的全部 API 都围绕这四样东西:
| 概念 | 职责 | 类比 |
|---|---|---|
router | 把若干过程聚成一棵树,导出 AppRouter 类型 | Express 的 Router |
procedure | 一个可远程调用的函数(query / mutation / subscription) | 一个 REST 端点 |
context | 每次请求构造一次的依赖容器(db、当前用户、请求 ID) | 中间件挂载的 req |
middleware | 在过程前后插入逻辑,并能收窄 context 类型 | Express middleware |
关键差异在于 middleware:Express 的中间件只能往 req 上挂东西,类型全靠 declare global 声明;tRPC 的中间件通过 next({ ctx }) 返回一个新的 context 类型,编译器会沿着调用链把这个新类型传下去。
16.1.3 初始化:initTRPC 与错误格式化
第一步是把 initTRPC 实例建出来,并把 context 类型钉死。这一步决定了后面所有过程的 ctx 长什么样。
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { ZodError } from 'zod'
import type { Db } from './db'
export interface Context {
db: Db
user: { id: string; role: 'admin' | 'user' } | null
reqId: string
}
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const router = t.router
export const publicProcedure = t.procedure
export const middleware = t.middleware
errorFormatter 是第一个值得花时间的地方。Zod 校验失败时,tRPC 默认只返回 BAD_REQUEST 与一句 "Invalid input",前端拿不到「哪个字段错了」。把 error.cause.flatten() 塞进 data.zodError 后,前端就能把错误精确映射到表单项——这正是 13.2 表单类型推导与错误映射
里讨论的那类需求。
注意 initTRPC 只应该被调用一次,且 trpc.ts 这个文件不能反过来 import router.ts,否则会形成循环依赖,典型报错是运行期的 Cannot access 'router' before initialization。
16.1.4 声明过程:输入校验与输出形状
有了 t,就可以声明 router 了。tRPC 用 .input() 接收任意 Standard Schema(Zod 是最常用的实现),校验通过后 input 参数自动获得推导类型。
// server/router.ts
import { z } from 'zod'
import { router, publicProcedure } from './trpc'
const UserShape = z.object({
id: z.string().uuid(),
userName: z.string(),
email: z.string().email(),
})
export const appRouter = router({
user: router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const row = await ctx.db.user.findUniqueOrThrow({
where: { id: input.id },
})
return { id: row.id, userName: row.name, email: row.email }
}),
create: publicProcedure
.input(
z.object({
name: z.string().min(1).max(32),
email: z.string().email(),
}),
)
.mutation(async ({ input, ctx }) => {
const row = await ctx.db.user.create({ data: input })
return { id: row.id }
}),
}),
})
// 全书唯一需要导出的东西:类型
export type AppRouter = typeof appRouter
三点值得留意:
query与mutation是语义约定,不是 HTTP 动词。tRPC 默认全部走POST,query表示「可缓存、幂等」,mutation表示「有副作用」——这个区分直接驱动了客户端库的重试与失效策略。input的类型来自 Zod,ctx的类型来自initTRPC,两者都不需要手写。把.input()里的id改成z.number(),客户端的调用点立刻报错。- 输出类型是
query函数返回值的推导结果,默认不做运行期校验。如果服务端返回了passwordHash,它会被原样发给客户端。要显式裁剪,用.output():
const PublicUser = z.object({
id: z.string(),
userName: z.string(),
email: z.string(),
})
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.output(PublicUser)
.query(async ({ input, ctx }) => {
const row = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } })
// 多返回的字段会被 Zod 剥掉,且编译器会检查形状是否匹配
return { ...row, userName: row.name, passwordHash: row.passwordHash }
})
这与 5.1 HTTP 服务与路由(Fastify / Hono)
里 Fastify 用 response schema 做字段白名单是同一个思路:把「不许泄漏的字段」变成结构性保证,而不是靠每个 handler 自觉。
16.1.5 挂载到 HTTP 层
router 只是内存里的对象,需要一个适配器把它接到真实的 HTTP 服务器上。以 Fastify 为例:
// server/index.ts
import Fastify from 'fastify'
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'
import { appRouter } from './router'
import type { Context } from './trpc'
const app = Fastify({ logger: true })
export function createContext({ req }: { req: FastifyRequest }): Context {
return {
db,
user: decodeToken(req.headers.authorization),
reqId: req.id,
}
}
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: { router: appRouter, createContext },
})
await app.listen({ port: 3000, host: '0.0.0.0' })
createContext 是每个请求执行一次的工厂函数,因此它天然是放「当前用户」「请求 ID」「事务句柄」的地方,语义上等价于 5.2 中间件与请求上下文
里讲的请求上下文。注意这里不要在 createContext 里建数据库连接——连接池应该在模块加载时建好,Context 只持有引用。
16.1.6 中间件与受保护过程
现在把鉴权抽出来。tRPC 中间件的返回值必须是 next(),并且可以把新字段合进 context:
// server/trpc.ts(续)
export const isAuthed = middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' })
}
return next({ ctx: { user: ctx.user } })
})
export const protectedProcedure = t.procedure.use(isAuthed)
isAuthed 里的 if (!ctx.user) throw 之后,ctx.user 的类型被收窄为 { id: string; role: 'admin' | 'user' }(去掉了 null),而 next({ ctx: { user: ctx.user } }) 把这个收窄后的类型继续往下传。于是在 protectedProcedure 里写 ctx.user.id 既不用 ! 也不用 as:
const me = protectedProcedure.query(async ({ ctx }) => {
return { id: ctx.user.id, role: ctx.user.role } // ctx.user 不再是 null
})
这是 tRPC 最被低估的设计:鉴权失败从「运行期断言」变成了「类型系统里的分支」。角色守卫同理,在 protectedProcedure 上再叠一层 .use(),发现 ctx.user.role !== 'admin' 时抛 TRPCError({ code: 'FORBIDDEN' }) 即可,不需要新的类型体操。
中间件也是放日志与计时的合适位置。下面这段给每个过程打一条结构化日志,与 3.3 结构化日志与脱敏 的约定保持一致:
export const timing = middleware(async ({ ctx, path, type, next }) => {
const start = performance.now()
const result = await next()
ctx.log.info({
reqId: ctx.reqId,
path,
type,
ok: result.ok,
ms: Math.round(performance.now() - start),
})
return result
})
注意 next() 返回的是 { ok: true, data } | { ok: false, error } 这样的结果对象而不是直接抛异常,所以日志中间件不会因为下游报错而中断。要让它对全部过程生效,把 publicProcedure 的定义改成 t.procedure.use(timing) 即可。
16.1.7 客户端:类型如何流过来
服务端只多导出了一个 AppRouter 类型,客户端就能获得完整推导:
// web/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/router'
export const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
})
const user = await trpc.user.byId.query({ id: '9f1c…' })
// user 的类型:{ id: string; userName: string; email: string }
把服务端的 userName 改成 username 再重新编译,前端这一行会立刻报出:
Property 'userName' does not exist on type
'{ id: string; username: string; email: string; }'.
这就是端到端类型安全的全部含义:不存在「后端改了前端不知道」的窗口,因为前端根本没有一份独立的类型可漂移。httpBatchLink 还顺手把同一事件循环内的多个调用合并成一个 HTTP 请求,减少了 N+1 式往返。
在 React 项目里,通常再包一层 TanStack Query 适配器,缓存、失效、乐观更新全部复用:
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/router'
export const trpc = createTRPCReact<AppRouter>()
function UserCard({ id }: { id: string }) {
const { data, isLoading } = trpc.user.byId.useQuery({ id })
if (isLoading) return <Skeleton />
return <h2>{data.userName}</h2> // data 已推导为 PublicUser
}
关于缓存键与失效策略,直接沿用 14.1 TanStack Query 类型推导 的结论;tRPC 适配器生成的 query key 是结构化的,比手写字符串数组更安全。
16.1.8 三个真实高频坑
坑一:把 appRouter 当值导入,服务端代码被打进前端包。 这是新手最常见的错误。import { appRouter } from '../server/router' 会让打包器沿着依赖图把 db.ts、pg 甚至 dotenv 全部拖进浏览器构建,报错形如:
Module not found: Can't resolve 'pg' in './server'
解法只有一条:客户端永远只导入类型。若担心团队成员写错,可以在 ESLint 里加一条 no-restricted-imports 规则,或在 server/router.ts 顶部注释写明「此文件仅导出类型给客户端」。
坑二:忘了 superjson,Date 变成字符串。 tRPC 默认走 JSON 传输,Date 会被序列化成 ISO 字符串,但类型上仍然是 Date——这又是一个「类型说没问题、运行时是错的」的漏洞。修法是在两端同时配置 transformer:
// server
initTRPC.context<Context>().create({ transformer: superjson })
// client
createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [httpBatchLink({ url: '/trpc' })],
})
坑三:@trpc/server 与 @trpc/client 版本不一致。 tRPC 把类型协议放在包内部,两个包 minor 版本不同会出现「服务端类型明明对、客户端却推导成 any」或 Property 'user' does not exist on type 'DecoratedProcedureRecord'。用 pnpm 的 overrides 或 catalog: 强制同版本,与 2.1 路径别名与 monorepo 结构
里的版本对齐策略一致。
16.1.9 能力边界
tRPC 不是银弹,它的收益完全建立在「两端都是 TypeScript 且共享编译产物」这个前提上:
| 场景 | 是否适合 tRPC | 原因 |
|---|---|---|
| 同仓库的 Next.js 全栈应用 | 适合 | 类型直接共享,开发体验最好 |
| BFF 聚合层 | 适合 | 消费者只有自家前端 |
| 内部工具 / 管理后台 | 适合 | 迭代快,不需要对外文档 |
| 对外开放的公开 API | 不适合 | 消费者不是 TS,需要 OpenAPI 文档 |
| 多语言客户端(iOS / Go) | 不适合 | 类型无法跨语言 |
| 需要 CDN 缓存的只读接口 | 需评估 | 默认全 POST,无法利用 HTTP 缓存 |
另外,tRPC 的「端到端」只覆盖编译期。跨进程的边界(另一个团队的服务、第三方回调)依然需要运行期校验,这条线由 16.2 OpenAPI / GraphQL Codegen 承接。延伸阅读可参考 GraphQL 与 tRPC 对比 与 Node.js tRPC 类型安全 API 实践 。
小结
本节的核心结论是:契约漂移的根因是类型有第二个来源。
res.json()返回any,因此手写interface只是注释,不是契约;守住边界的前提是让类型从服务端实现推导出来;- tRPC 用
router/procedure/context/middleware四件套把服务端实现本身变成契约,客户端只导入一个AppRouter类型; initTRPC只调用一次,errorFormatter决定前端能否把校验错误映射到字段;.input()用 Zod 校验入参,.output()决定是否裁剪出参——出参不写.output()就没有运行期保护;middleware通过next({ ctx })收窄 context 类型,让ctx.user在受保护过程里不再是null,鉴权从运行期断言升级为类型分支;- 三个高频坑分别是「值导入 router 导致服务端代码进包」「漏配 superjson 导致 Date 失真」「两包版本错配导致类型失效」;
- 能力边界由消费者决定:全是自家 TS 前端就用 tRPC,需要跨语言或对外文档就转向下一节的代码生成路线。
下一节我们换一条路线:不共享类型,而是把接口描述抽成一份中立 schema,用代码生成把 schema 变成客户端类型——它牺牲了「零声明」的优雅,换来了跨语言与可文档化。
阅读导航:上一节:15.3 构建性能诊断与包体积治理 · 下一节:16.2 OpenAPI / GraphQL Codegen 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。