《TypeScript编程实战》16.1 tRPC 端到端类型安全

本节从前后端分离项目里最常见的契约漂移讲起,说明为什么手写接口类型守不住边界。随后完整走一遍 tRPC 的工程落地:初始化 router、用 context 传递请求级依赖、用 Zod 校验输入、用 middleware 做鉴权与日志,再把类型一路带到 React 客户端。最后给出 tRPC 的适用边界与常见坑,帮你在单体全栈与开放 API 之间做出有依据的选型。

本节目标:看清「契约漂移」是怎么发生的;掌握 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 codegenschema 文件要(写 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

三点值得留意:

  1. query 与 mutation 是语义约定,不是 HTTP 动词。tRPC 默认全部走 POST,query 表示「可缓存、幂等」,mutation 表示「有副作用」——这个区分直接驱动了客户端库的重试与失效策略。
  2. input 的类型来自 Zod,ctx 的类型来自 initTRPC,两者都不需要手写。把 .input() 里的 id 改成 z.number(),客户端的调用点立刻报错。
  3. 输出类型是 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes