前后端接口契约:别让 TypeScript 只保护前端自己

TypeScript 能让前端代码更稳,但它保护不了真实接口返回。

你在前端写了一个类型:

1type User = {
2  id: string
3  name: string
4  avatarUrl: string
5}

这只能说明“前端希望它长这样”。后端实际返回 avatar_urlavatarUrl: null、或者漏了字段,TypeScript 在运行时完全不知道。

我遇到过一个很典型的问题:列表页上线后偶现白屏,本地 mock 从来没复现。最后发现后端某些历史数据 namenull,前端类型写的是 string,组件里直接 user.name.trim()。类型没报错,线上直接炸。

所以接口契约的重点不是前端自己写类型,而是让前后端对同一份约定负责。

类型来源要尽量单一

上面那个 bug 的深层原因,是"约定"在系统里存在了不止一份。最差的情况是三份各写各的:后端有一套 DTO,接口文档里有一份字段说明,前端再照着手写一份 TypeScript 类型。三份东西只要有一份没跟上改动,前端的类型就在自欺欺人——它描述的是"我以为后端会返回什么",而不是"后端实际返回了什么"。手写类型这件事本身就是漂移的温床:它抄的是某个时间点的接口文档,抄完之后就和源头断了联系,后端再改,这份手抄的类型不会有任何反应,前端却一直信以为真,直到线上出问题才发现两边早就对不上了。

要治本,就得让类型从唯一的契约生成,而不是手抄。这份契约可以是 OpenAPI/Swagger 文档、GraphQL 的 schema、tRPC 这类前后端直接共享类型的方案、后端导出的 JSON Schema,或者干脆是一个前后端都依赖的 schema npm 包。选哪种取决于团队栈,但共同点是:类型有一个单一事实来源,改一处、两边同步。以 OpenAPI 为例,用 openapi-typescript 之类的工具把文档生成为类型后,前端直接引用:

1type User = components['schemas']['User']

这样后端一改契约——字段改名、必填变可选、枚举新增——前端跑一次生成就能在类型层立刻感知,改动会以编译错误的形式摆到你面前,而不是等上线后由用户替你发现。

tRPC 那条路和 OpenAPI 生成是两种不同的哲学。OpenAPI 是"契约先行"——先有一份独立于两端的文档,两边各自向它对齐;tRPC 是"共享实现"——前后端在同一个 TypeScript 仓库里,后端定义的过程签名直接被前端 import,类型不是生成的、是同一份源码。tRPC 的类型安全更彻底、也没有生成这一步的延迟,但它的前提很硬:前后端得是同一套 TypeScript 技术栈、最好在一个 monorepo 里。我们是 Java/Go 后端加前端的组合,跨语言,tRPC 这条路根本走不通,只能走 OpenAPI 这种语言中立的契约。所以选哪种,很多时候不是偏好,是被技术栈现实卡死的——跨语言就只能契约先行,同构 TS 才谈得上共享实现。GraphQL 的 schema 又是第三种,它把"要哪些字段"的选择权交给前端,类型同样能从 schema 生成,适合字段组合多变的场景,代价是后端要维护一套 resolver 和 schema 治理,不是所有团队都愿意背这个复杂度。

当然,生成类型不是银弹,它的可靠性完全绑定在契约的可靠性上。接口文档必须准确,后端得把维护文档当成和写代码同等的交付物。如果 Swagger 只是"看起来有"、实际和返回对不上,那生成出来的类型只是把错误包装得更像回事而已。真正靠谱的做法是让契约成为后端 CI 的一部分:从代码里的注解或 DTO 自动生成 OpenAPI,而不是让人手动维护一份注定要漂移的文档。

openapi-typescript 生成出来的东西有个习惯要适应:它把所有 schema 塞进一个大的 components['schemas'],路径和方法塞进 paths,直接用会很啰嗦。实际项目里我一般在生成产物外面再包一层薄薄的别名,把常用的类型抽出来,让业务代码引用起来干净:

1import type { components, paths } from './generated/api'
2
3export type User = components['schemas']['User']
4export type CreateUserBody =
5  paths['/users']['post']['requestBody']['content']['application/json']
6export type CreateUserResp =
7  paths['/users']['post']['responses']['200']['content']['application/json']

这层别名还有个好处:生成产物是每次拉契约就整个覆盖的,不能手改;把稳定的对外名字放在别名文件里,即使生成器换了、路径结构变了,业务代码引用的名字不用跟着动,改动被隔离在别名这一层。生成这一步我会把它挂进 package.json 的脚本,配合后端提供的契约地址或者本地的 openapi.json 跑,--immutable 之类的选项能顺带保证产物没被手动改过。

选生成方案时也有取舍。openapi-typescript 只生成类型、不生成请求代码,轻,但你得自己写请求层;openapi-fetch 这类能把类型和一个类型安全的 fetch 客户端一起给你,路径、参数、响应全带类型,代价是多引一个运行时依赖、并且被它的调用范式绑住。团队如果已经有自己封装好的请求库,前者更好嵌;从零起或者想要端到端类型安全,后者省事。这不是哪个更好的问题,是看你的请求层现状。

错误响应也要有契约

很多团队只认真设计成功响应,错误响应随缘。

1{ "message": "失败" }

或者不同接口各写各的:

1{ "error": "NO_AUTH" }
2{ "code": 401, "msg": "未登录" }
3{ "success": false, "data": null }

前端最后只能到处写兼容逻辑。

我更希望错误响应至少稳定包含:

1type ApiError = {
2  code: string
3  message: string
4  requestId: string
5  fields?: Record<string, string>
6}

code 给程序判断,message 给默认展示,requestId 给排查日志,fields 给表单错误回填。这四个字段各有各的用途,最关键的是 codemessage 的分工:code 是给机器看的稳定标识,永远不该拿来在界面上直接展示;message 是给人看的、可以随时改文案。很多团队把这两件事混成一个字段,结果前端只能靠 message 里的中文字符串去判断错误类型——文案一改,判断就全断了。能用来做逻辑分支的必须是稳定的 code,绝不能是会变的 message

requestId 这个字段容易被前端当成"后端自己排查用的、跟我无关"而忽略掉,其实它是把前端报错和后端日志串起来的唯一线索。用户截图报错时,界面上如果带着 requestId,后端拿着它能直接定位到那一次请求的完整日志,省掉大量"这个错到底哪来的"的扯皮。我一般会在错误提示的角落里把 requestId 也显示出来,或者至少上报到监控里,让它和前端的错误事件绑在一起。

前端处理错误时不要只看 HTTP status,也不要只看 message:

1function handleApiError(error: ApiError) {
2  if (error.code === 'VALIDATION_ERROR') {
3    setFieldErrors(error.fields ?? {})
4    return
5  }
6
7  if (error.code === 'UNAUTHORIZED') {
8    redirectToLogin()
9    return
10  }
11
12  toast.error(error.message || '操作失败,请稍后重试')
13}

API 错误必须有明确处理路径。否则线上只会剩下一堆“接口报错了”的模糊反馈。

HTTP status 和业务 code 的关系也得先约定清楚,不然前端两头都要猜。常见的两种风格:一种是严格用 HTTP 语义,401 就是未登录、403 就是无权限、422 就是校验失败,code 只在同一个 status 下细分;另一种是接口一律返 200,业务成败全靠 body 里的 code 判断。两种都能用,但团队内必须统一,最怕的是有的接口按 HTTP 语义、有的接口一律 200,前端只能每个接口单独摸清它是哪种脾气。我个人偏向前者——让 HTTP status 承担粗粒度的分类(认证、权限、限流、服务端错误),code 承担业务细分,这样连拦截器都好写:在 axios 或 fetch 封装的响应拦截里,按 status 统一处理登录失效、限流重试这类跨接口的通用逻辑,业务 code 再交给各自的调用方。

拦截器这层是错误处理能不能收敛的关键。如果每个调用点都自己 try/catch、自己判 status,错误处理逻辑会散得到处都是,改一个规则要改几十处。把"解析 ApiError、区分网络错误和业务错误、统一上报"这些都收进请求封装的一处,业务代码里就只剩下处理自己关心的那几个 code,其余的走默认路径。这也是为什么错误响应要有稳定契约——契约稳定,拦截器才敢按一套固定结构去解析,不用为每个接口写适配。

运行时校验补上最后一公里

即使有生成类型,我仍然建议在关键边界做运行时校验。

因为类型只在编译期存在,真实数据来自网络、缓存、第三方系统和历史脏数据。关键页面如果不能接受脏数据,就应该在入口校验。开头那个 namenull 的白屏就是活例子:类型写着 string,编译器一路放行,直到运行时 user.name.trim() 撞上 null 才炸。运行时校验的意义就是在数据进入业务逻辑之前设一道闸,把"类型说该是什么"和"数据实际是什么"的差异在入口处就拦下来,而不是让它一路渗到某个 .trim()、某个 .map() 上才以一种莫名其妙的形式爆出来。

这里要分清一件事:生成类型和运行时校验解决的是两个不同阶段的问题,不是二选一。生成类型保证的是"前端代码对契约的理解是最新的",属于编译期;运行时校验保证的是"实际拿到的数据真的符合契约",属于运行期。契约本身可能是对的,但后端有历史脏数据、或者某个边缘 case 没按契约走,这时候类型再准也拦不住,只有运行时校验能兜。两者叠起来,才既防住了"前端理解过时",又防住了"数据不守规矩"。

1import { z } from 'zod'
2
3const UserSchema = z.object({
4  id: z.string(),
5  name: z.string(),
6  avatarUrl: z.string().nullable(),
7})
8
9async function fetchUser(id: string) {
10  const json = await request(`/api/users/${id}`)
11  return UserSchema.parse(json)
12}

校验失败时也要处理,不要让错误直接白屏:

1try {
2  const user = UserSchema.parse(json)
3  return user
4} catch (error) {
5  reportError('api.user.invalid_response', error, { json })
6  throw new Error('用户数据格式异常')
7}

parse 失败会直接抛,适合"数据不对就没法继续"的关键边界。但很多列表场景里,一条脏数据不该拖垮整页,这时候 safeParse 更合适——它不抛异常,返回一个带 success 标志的结果,你可以逐条校验、把坏的那条剔掉或降级,好的照常渲染:

1const result = ItemSchema.safeParse(raw)
2if (!result.success) {
3  reportError('api.item.invalid', result.error, { raw })
4  return null // 这一条丢掉,不影响列表其余项
5}
6return result.data

parse 还是 safeParse,本质是"这份数据错了要不要让整个流程中断"的判断——关键详情页宁可整页报错也不能展示错数据,用 parse;列表、feed 这种"少一条无所谓"的,用 safeParse 做容错。

Zod 还有个容易被忽略的能力是数据规整,transform 能在校验的同时把数据洗成前端想要的形状。比如后端给的是 avatar_url、前端想用 avatarUrl,或者后端给字符串日期、前端想要 Date,都能在 schema 里一步做掉,省得在组件里到处手动转:

1const UserSchema = z.object({
2  id: z.string(),
3  avatar_url: z.string().nullable(),
4  created_at: z.string(),
5}).transform((u) => ({
6  id: u.id,
7  avatarUrl: u.avatar_url,
8  createdAt: new Date(u.created_at),
9}))

这样校验层顺带成了防腐层,命名风格、类型转换都收敛在接口入口一处,后端字段风格再乱,也不会渗进业务代码。不过 transform 用多了会让 schema 变重,我一般只在字段命名或类型确实需要归一的接口上用,纯读的地方不折腾。

不是每个接口都要上严格校验。我的原则是:权限、支付、配置、核心详情页、跨团队接口优先校验;普通低风险列表可以先靠类型加一个默认的容错分支撑着。校验也不是免费的,Zod 在运行时要逐字段跑一遍规则,超大列表逐条 parse 会有可感的开销,这也是为什么要挑最容易出错的接口先上——把成本花在最该拦的地方,而不是每个接口无脑套一层。这一年 Zod 在社区基本是运行时校验的默认选择,生态里也有 valibot 这类主打更小体积的替代品,取舍点通常是"包大小"对"生态成熟度",我目前还是用 Zod 更多,插件和示例都全。

Mock 要从契约生成

Mock 数据如果手写,很容易和真实接口分叉。

比如前端 mock 里 status 只有 pendingdone,线上后端新增了 cancelled,前端没处理就出问题。手写 mock 有个隐蔽的偏向:人总是倾向造"顺利的那一条"——字段都齐、值都正常、列表都有数据。于是开发阶段看到的永远是理想数据,空列表、null 字段、超长文本、加载失败这些真实世界天天发生的情况,反而要等上线才第一次撞见。这不是 mock 本身的错,是手写时人的惰性,得靠机制去纠正。

如果契约里有枚举,mock 的取值该把每一种极端情况都盖到:

1type OrderStatus = 'pending' | 'paid' | 'cancelled'

测试数据至少要包含每一种状态,而不是只给最顺利的一条。

我更喜欢把 mock 当成契约测试的一部分:字段缺失、空数组、null、超长文本、错误码都要有样本。这样前端在开发阶段就能看到极端情况。

让 mock 不和真实接口分叉,最省事的办法还是让它从同一份契约生出来。既然类型能从 OpenAPI 生成,mock 数据也能——msw(Mock Service Worker)配合从 OpenAPI schema 生成的假数据,能在浏览器网络层拦截请求返回符合契约的响应,比手写一堆 mock 对象可靠:它拦的是真实的 fetch/XHR,前端代码一行不用改,开发时用 mock、上线走真接口,切换只在于开不开这个 worker。schema 里声明了枚举、nullable、字段必填与否,生成器就能照着造出覆盖这些极端情况的数据,而不是永远只给一条最顺的。

更进一步是把 mock 直接接进测试。用 msw 在单测里模拟"后端返回了一个前端没见过的枚举""某字段返回了 null""接口直接 500"这些场景,验证组件的默认分支真的接住了这些异常。这比手动构造假数据更接近真实——它走的是完整的请求-解析-渲染链路,包括你的 Zod 校验和错误拦截器,能一起测到。契约、类型、mock、测试用的是同一份 schema,四者才不会各说各话。

变更要分兼容和不兼容

接口一改,最不该出现的一句话是"前端改一下就行"。改动到底要不要前端配合、要不要卡发版时序,取决于它是兼容变更还是不兼容变更,这条线得先划清楚。

兼容变更是那些"老前端代码继续跑也不会炸"的改动:新增一个可选字段、新增一个前端本来就留了默认处理的枚举值、上线一个全新接口。这类改动后端可以自己发,前端慢慢跟。不兼容变更则是会让老代码直接出错的:删字段、把必填改成可选(前端可能没做空判断)、字段类型变了、枚举值的语义变了、错误码结构变了。这类改动一旦前后端不同步上线,中间那段时间就是线上事故窗口。

有几个改动看着像兼容的、其实是不兼容的,特别容易被"前端改一下就行"糊弄过去。"把必填改成可选"是最典型的一个:从后端视角这是放宽约束,很无害;但前端如果基于"这个字段一定有"写了代码,字段突然可能不来,就直接踩空——这本质和删字段一样。"给枚举加个新值"通常算兼容,前提是前端的 switch 留了 default 分支;一旦前端是穷尽式 switchdefault 会抛错,那新增枚举对它就是不兼容的。所以兼容不兼容不能只从后端单方面看,得看前端实际是怎么消费这个字段的——同一个后端改动,对留了默认分支的前端是兼容的,对没留的前端就是不兼容的。这也是为什么前面那些容错手段不是可有可无的装饰,它们直接决定了后端有多大的自由度去演进接口。

不兼容变更必须配迁移计划,不能靠"两边同时上线"赌时序。最稳的做法是后端先双写、或者新旧格式兼容一段时间,等前端切干净了再删旧字段。

1{
2  "avatarUrl": "https://cdn.example.com/a.png",
3  "avatar": "https://cdn.example.com/a.png"
4}

这看起来有点笨,但比前后端同时上线赌时序安全。尤其是多端应用,Web、iOS、Android、小程序发版节奏不一样,后端不能只按 Web 的节奏删字段——Web 发版当天就能全量,但 App 用户升级是长尾的,一个月后还有人用着老版本,后端要是按 Web 的节奏删了字段,那批没升级的 App 用户就直接炸。多端场景下"删字段"这件事的窗口期,得按最慢那个端的存量清空来算,而不是最快那个端。

真正把不兼容变更管起来,靠人肉记性是不够的,得让 CI 帮忙盯。业界有 API 契约的破坏性变更检测工具,拿新旧两版 OpenAPI 文档做 diff,删字段、改类型、必填变可选这类破坏性改动会被自动标红、卡住合并。后端 CI 里加这么一道,就把"哪些是不兼容变更"从人的判断变成了机器的门禁——不用等前端上线炸了才发现某个字段被悄悄删了。前端侧对应的保险,是把生成类型这一步也放进 CI:定时或在契约更新时重新生成,一旦生成结果和仓库里的对不上,说明契约变了而前端还没跟,CI 直接失败,提醒你去看是不是有破坏性改动要处理。

版本化是另一条路,但要慎用。给接口加 /v1/v2 前缀,让不兼容变更走新版本、老版本继续伺候存量,听起来干净,代价是后端要长期维护多个版本、前端要管理调哪个版本的心智负担。小改动动辄升版本会让接口版本爆炸,最后谁都记不清哪个端在用哪版。我的经验是:能用"双写过渡+删旧字段"解决的,优先走过渡;只有那种真的大改、语义彻底变了的接口,才值得动用版本号这个重武器。

把契约漂移挡在合并之前

前面几节的手段——生成类型、运行时校验、mock、默认容错分支——都是前端在自己这侧筑墙。但最理想的状态是漂移根本进不了主干,这就要把契约校验搬到 CI 里,让它成为合并的前置条件,而不是上线后的补救。

最基础的一道是生成类型的一致性检查。把类型生成绑进 CI,跑完之后 git diff 一下生成目录,如果有变化说明契约变了而仓库里的类型还没跟:

1npm run gen:api-types
2git diff --exit-code src/generated/api.ts \
3  || (echo "契约已变更,请重新生成类型并提交" && exit 1)

--exit-code 让有差异时直接非零退出、卡住流水线。这一步逼着"改了契约就必须重新生成并提交"成为硬约束,杜绝了"后端悄悄改了、前端类型还停在旧版"这种最难查的漂移。

再往上是契约测试(consumer-driven contract)的思路:前端把"我依赖后端的哪些字段、什么形状"表达成一份可执行的期望,后端 CI 拿这份期望去校验自己的实际响应。这样后端删一个前端还在用的字段,在它自己的流水线上就会失败,不用等联调甚至上线才暴露。这套东西落地成本不低,需要两端都接入工具、维护期望文件,我们目前只在几个跨团队、变更频繁的核心接口上用,普通内部接口还是靠生成类型加运行时校验兜。要不要上契约测试,本身也是个取舍——接口稳定、团队小的场景,这套流程的维护成本可能比它挡下的事故还高。

未知值的兜底,是契约的最后一道防线

前面几节都在讲怎么让约定更可靠,但再可靠的契约也架不住"上游偷偷加了个新枚举忘了通知"。所以前端还得留一条退路:对未知枚举和未知错误码,永远有个不炸的默认分支。

switch 处理枚举时,别只写已知的几个 case 就完事,default 分支要能优雅降级,而不是让界面渲染出一个空白或者直接抛错:

1function renderStatus(status: OrderStatus | string) {
2  switch (status) {
3    case 'pending': return <Tag color="orange">待处理</Tag>
4    case 'paid': return <Tag color="green">已支付</Tag>
5    case 'cancelled': return <Tag color="gray">已取消</Tag>
6    // 后端新增了前端还不认识的状态,别让它把页面搞崩
7    default: return <Tag>{String(status)}</Tag>
8  }
9}

这里我特意把类型写成 OrderStatus | string 而不是纯 OrderStatus,就是提醒自己:运行时真实拿到的值可能超出类型定义的范围,类型层的"穷尽"在运行时并不成立。这背后是 TypeScript 一个容易让人误判的地方——它的类型只在编译期存在,一旦数据从网络进来,类型标注就只是"我以为",运行时的值完全可以不遵守。所以 switch 的穷尽性检查(用 never 收口那种)在纯前端逻辑里是好东西,但用在接口枚举上就是个陷阱:它会让你误以为已经处理了所有情况,而真实的后端随时能塞进来一个类型里没有的值。

这里有个取舍值得说清楚:OrderStatus | string 这个写法本质上是在类型安全和运行时安全之间做了个妥协。写成纯 OrderStatus,编译期能享受到穷尽检查(漏了一个 case 编译器会提醒),但运行时不认识的值会漏进 default 甚至崩掉;写成 | string,运行时安全了,但等于告诉编译器"这里什么字符串都可能",穷尽检查就失效了。我的折中是:枚举定义处保持纯 OrderStatus 享受穷尽检查,只在渲染这类直面运行时数据的函数入口把参数放宽成 | string,并且 default 分支一定要能优雅降级。这样编译期的好处和运行时的容错各得其所,不用二选一。

错误码同理,handleApiError 里那句 toast.error(error.message || '操作失败') 就是给所有没被显式处理的 code 留的活路——哪怕后端加了个前端没见过的 code,用户至少能看到一句人话,而不是卡在半路。这也是为什么前面强调 message 要是一句能直接展示给用户的人话:它是这条退路上唯一还能用的信息,code 前端不认识,就只剩 message 能救场了。

回到最初那个 null 引发的白屏:它之所以能上线,是因为前端类型、真实数据、错误处理这几层里没有一层拦住它。契约做对了,无非是让这几层各自补上——类型来自同一份约定而非手写猜测,成功和失败响应都有稳定结构,关键边界做运行时校验,Mock 覆盖到空值和异常,不兼容变更留过渡期,未知值有兜底。接口契约的目标从来不是让文档更好看,而是把"你以为"和"我以为"之间的缝隙一条条堵上。前后端共享同一份事实,那次让 user.name.trim() 炸在生产环境的 null,才会在类型生成、运行时校验或者兜底分支里的某一层被拦下来,而不是又扛到下一次白屏才被发现。