前后端类型契约:用 OpenAPI 生成前端类型,让接口改动在编译期报错

作者:忆笙智云官方 | 发布时间:2026-11-04 00:26 | 更新时间:2026-11-04 00:26

一句 git blame 查不出来的问题

后端把 userName 改成了 username,IDE 里重命名一按,Java 侧全绿。前端那边呢?编译通过,测试通过,上线之后某个表格的姓名列全部空白。

这类问题在前后端分离的项目里几乎是常态,原因不复杂:同一个数据结构,在两个地方各写了一遍,而没有任何机制保证它们一致。

后端写的是 UserVO,前端写的是 interface UserVO。两个名字一样,字段看起来也一样,但它们之间唯一的联系是"某次联调时对齐过"。之后任何一侧的改动,都靠人记得通知对方。

这篇文章讲的是怎么把这条"靠人记得"的线,变成"编译期会报错"的线。工具是两个:springdoc-openapi 和 openapi-typescript。都不复杂,难的是把它接进流程,以及接受它的局限。

类型漂移的三个源头

先看清楚问题出在哪,不然接完工具还是会有漏洞。

源头一:后端有两套模型,前端只对着其中一套写。

很多项目里,实体类(Entity)和视图对象(VO)是分开的,这本身是对的。但前端拿到的那份 interface,往往是照着某一次接口响应手抄的,抄的时候可能已经和最新的 VO 不一致了。接口返回什么,前端就写什么,至于 VO 里还有哪些字段没返回、哪些字段是条件返回的,前端根本不知道。

源头二:序列化层的加工没有体现在类型上。

后端做了 Long 转 String、日期格式化、null 字段隐藏(上一篇文章里讲过的那几个坑),这些加工发生在序列化阶段。前端的 interface 如果照着 Java 类型写,就会写成 id: number,而实际收到的是 "1874213698257413634"。类型写着 number,运行时是 string,这种"类型撒谎"比没有类型更危险,因为它会让 TS 把错误放行。

源头三:条件字段。

data 在成功时是对象,失败时是 null;list 只在分页接口里有。手写 interface 的时候为了省事,往往写成可选字段,然后在业务代码里到处 ?. 和 !。这不是类型严格,是类型模糊。

这三个源头都指向同一件事:类型应该从接口定义生成,而不是从接口响应抄写。

让后端先产出一份机器可读的契约

OpenAPI 是一份描述 HTTP 接口的 JSON/YAML:有哪些路径、每个路径接受什么参数、返回什么结构。它本来是写文档用的,但因为它机器可读,就能拿来生成代码。

Spring Boot 项目生成它只需要一个依赖:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>

启动之后:

不用写任何配置,它靠扫描 @RestController 自动生成。这就是它能落地的前提——零成本起步,不需要团队先写一遍注解才有产出。

但自动生成的 spec 质量参差不齐,主要差在描述上。加上注解会好很多:

@Operation(summary = "查询用户详情")
@GetMapping("/users/{id}")
public Result<UserVO> detail(
        @Parameter(description = "用户 ID") @PathVariable Long id) {
    return Result.ok(userService.detail(id));
}
@Schema(description = "用户信息")
public class UserVO {

    @Schema(description = "用户 ID(字符串形式,避免前端精度丢失)", type = "string")
    private Long id;

    @Schema(description = "用户名", example = "zhangsan")
    private String username;
}

@Schema(type = "string") 那一行是关键。它不改变 Java 类型,只改变 spec 里这个字段的类型声明。如果后端确实把 Long 序列化成了字符串(上一篇讲的 ToStringSerializer),就必须在这里标上,否则生成的 TS 类型是 number,又变成"类型撒谎"。

这一步做完,后端就有了一个"对外承诺":这份 spec 就是接口的真实形状,文档、前端类型、测试用例都以它为准。

有个细节容易被忽略:springdoc 会读 Jackson 注解。这意味着改字段名时用 @JsonProperty 而不是改 Java 字段名,spec 和前端类型都会跟着变,不用两头改。

@Schema(description = "创建时间")
@JsonProperty("createdAt")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

这个字段在 Java 里叫 createTime,但 JSON 里叫 createdAt,生成的 TS 类型也是 createdAt: string。如果只改 Java 字段名而不加 @JsonProperty,前端类型会跟着变成新名字,所有引用处编译报错——这其实是好事,说明契约真的是从代码生成的,没有中间层骗人。

反过来说,如果某个字段不该出现在接口里,@JsonIgnore 一加,spec 自动去掉它,前端类型也跟着消失。这一套不需要维护两份声明。

前提是团队别在 spec 生成之后手动改它。一旦有人为了"让前端能用"去改 openapi.json,这条链就断了,后面所有生成都建立在假数据上。要改就改 Java 代码。

用 openapi-typescript 生成前端类型

前端装两个开发依赖:

npm i -D openapi-typescript typescript

生成:

# 从本地文件
npx openapi-typescript ./openapi.json -o ./src/types/api.d.ts

# 直接从运行中的服务拉
npx openapi-typescript http://localhost:8080/v3/api-docs -o ./src/types/api.d.ts

输出是一个 .d.ts 文件,纯类型,没有运行时代码。这一点很重要:它不会给打包体积增加任何字节,也不会因为生成了几千行就在运行时变慢。

文件里主要有两块:

// 所有接口路径的类型
export interface paths {
  "/users/{id}": {
    get: {
      parameters: { path: { id: number } }
      responses: {
        200: {
          content: { "application/json": components["schemas"]["ResultUserVO"] }
        }
      }
    }
  }
}

// 所有数据模型的类型
export interface components {
  schemas: {
    UserVO: {
      id?: string
      username?: string
    }
    ResultUserVO: {
      code?: number
      msg?: string
      data?: components["schemas"]["UserVO"]
    }
  }
}

用法有两种。一种是直接取模型类型:

import type { components } from '@/types/api'

type UserVO = components['schemas']['UserVO']

另一种是拿路径类型去约束请求函数,这样"URL 写错"和"参数传错"都能在编译期发现。手写一个薄封装:

import type { paths } from '@/types/api'

type Api = <P extends keyof paths, M extends keyof paths[P]>(
  path: P,
  method: M,
  options?: {
    params?: paths[P][M] extends { parameters: infer T } ? T : never
    body?: paths[P][M] extends { requestBody: { content: { 'application/json': infer B } } }
      ? B
      : never
  }
) => Promise<unknown>

export const api: Api = (path, method, options) => {
  return axios.request({ url: path as string, method: method as string, ...options })
}

这段类型写起来绕,但它带来的效果很直接:api('/user/{id}', 'get', { params: { path: { id } } }) 里如果路径拼错一个字符,TS 直接报错;id 传成字符串,也报错。

如果不想自己写这层,社区有 openapi-fetch,专门做这件事,类型和运行时都封装好了,代码量更少。

让 CI 守住这条契约

生成一次不难,难的是让它一直是新的。常见的失败方式是:一开始大家都用生成的类型,半年后有人发现"生成的文件是旧的",于是手动改了两行,从此这个文件就变成半手工维护,再也没人信它。

守住的办法是把生成结果提交到仓库,然后在 CI 里做一次校验:

npx openapi-typescript ./openapi.json -o ./src/types/api.d.ts
git diff --exit-code ./src/types/api.d.ts

如果生成的文件和仓库里的不一致,git diff 会返回非零,CI 失败。这意味着:后端改了接口、但没有更新前端类型,这个 PR 过不了。

正向的收益是,后端改字段名之后:

  1. 重新生成 spec;
  2. 重新生成 TS 类型;
  3. 前端所有用到旧字段名的地方,编译报错。

错误从"上线后发现某列空白"提前到了"本地 npm run build 就红"。这就是这套方案的全部价值所在,没有更多了——它不解决运行时问题,只负责让类型不一致变得不可忽视。

流程上有个现实问题:spec 从哪来?三种做法。

做法一:后端起服务,CI 里拉 /v3/api-docs。 最准,但要起服务,CI 流程复杂一点。

做法二:构建时把 spec 落成文件,提交到仓库。 后端 Maven 构建里加一个步骤导出 openapi.json,前端从这个文件生成。好处是可以 review 接口变更的 diff,坏处是两份仓库要同步节奏。

做法三:spec 作为构建产物,通过制品库传递。 最规范,小团队用不上。

多数项目用第二种就够。关键是spec 本身进版本管理,这样"谁在什么时候改了接口"是可追溯的,而不是只能靠猜。

破坏性变更该怎么处理

接上 CI 校验之后,会立刻遇到一个新问题:后端一改接口,前端 CI 就红,但这次改动其实是安全的。

所以要先分清哪些变更是破坏性的,哪些不是。判断标准不是"看起来像不像小改动",而是"旧版本的前端代码还能不能正常工作"。

安全的变更:

破坏性的变更:

新增枚举值这条最微妙。从类型角度看它安全,前端不会崩;但从业务角度看,前端大概率会有 switch 或者 if/else 穷举所有状态,遇到没见过的值就走到 default 分支,显示空白或者默认样式。所以它不是类型破坏,是逻辑破坏。

处理方式是:前端对所有枚举都用"带兜底的映射"。

const STATUS_MAP: Record<string, { label: string; color: string }> = {
  DRAFT: { label: '草稿', color: 'info' },
  PAID: { label: '已支付', color: 'success' }
}

const status = STATUS_MAP[row.status] ?? { label: row.status ?? '未知', color: 'default' }

?? 那一段是关键。有新状态时页面不会空白,而是显示原始 code,至少能让人看出"这里有个我没定义的值",而不是静默变成空白让人查半天。

真正需要破坏性变更的时候,两种做法:

做法一:加版本。 /api/v1/users 和 /api/v2/users 并存,前端分批迁移,老接口在下个季度下线。成本是后端要维护两套,收益是前端可以按自己的节奏走。接口数量多、调用方多(比如有外部合作方)的时候,这是唯一可行的方案。

做法二:一次性改完。 后端改、前端同时改、一起发版。前提是前后端在同一个发布单元里,能保证"要么都上,要么都不上"。单体仓库、或者前后端由同一批人维护的项目,这个方案最省事。

怕的是第三种情况:后端先上线、前端"下周跟上"。中间那几天,线上就是坏的。这种事发生一次,团队就会开始怀疑 CI 校验的价值——"它天天红,我们天天忽略"。所以一旦接上校验,发布节奏必须跟着调整,不能只加检查不改流程。

生成的类型不能解决什么

写到这里必须泼一盆冷水,否则用起来会失望。

一、Long 还是 number,不会自动变成 string。

OpenAPI 里 Long 是 integer + format: int64,而 TypeScript 只有一种数字类型,生成出来必然是 number。如果后端做了字符串序列化但没在 @Schema 里标 type = "string",生成的类型就是错的。

这个问题无法靠工具自动解决,因为工具的输入就是错的。上一篇文章讲的 Long 精度问题,在这套流程里表现为"生成完还要人工核对一遍 int64 字段"。想彻底解决,只能在后端把所有对外的 Long 都在 @Schema 上标成字符串,让 spec 一开始就是对的。

核对不用一个一个翻代码,可以直接扫 spec:

grep -o '"format": *"int64"' openapi.json | wc -l

数量不多的话,用 jq 把带 int64 的字段路径列出来,逐个确认是"真的当数字用"还是"其实是 ID"。前者保留 number,后者补 @Schema(type = "string")。

这个检查做一次就够了,之后新加的字段只要沿用同样的规范,不会反复出问题。真正需要警惕的是"某天有人加了个雪花 ID 但忘了标",所以最好把这条写进后端接口规范,而不是靠 review 时的记性。

二、日期是 string,不是 Date。

OpenAPI 里 format: date-time 生成出来是 string(有时是 string | null)。类型层面它就是一个字符串,dayjs(user.createdAt) 和 user.createdAt.getTime() 在 TS 看来后者会报错——这是好事,它逼你在边界处做解析。但别指望类型能告诉你"这个字符串带不带时区"。

三、泛型包装会被展开成一堆具体名字。

Result<UserVO>、Result<List<OrderVO>> 在 spec 里往往被展开成 ResultUserVO、ResultListOrderVO 这样的具体 schema。类型是对的,但名字很难看,而且嵌套层级深的时候,悬停提示会显示一长串。这是 OpenAPI 的表达能力限制,不是工具的错。

四、anyOf / oneOf 生成的是联合类型,收窄要自己写。

type Data = { type: 'a'; a: string } | { type: 'b'; b: number }

判别联合类型有了,但 if (data.type === 'a') 之后 TS 能不能正确收窄,取决于 spec 里有没有写 discriminator。没写的话,就得自己写类型守卫。这部分的体验不如手写类型顺滑。

五、它管不到业务逻辑的约定。

"这个接口要求先调登录接口拿 token"、"这个字段只在状态为 X 时有意义",这些 spec 里可以写成描述文本,但不会变成类型约束。类型契约只覆盖结构,不覆盖流程。

六、它保证不了运行时数据真的符合类型。

这是最根本的一条。生成出来的类型是声明,不是校验。后端某个分支返回了 data: null 而类型写着 data: UserVO,TS 不会知道,运行时也不会报错,只会在用 data.name 的时候崩。

也就是说,OpenAPI 生成的类型解决的是"前后端声明不一致",不解决"实现和声明不一致"。后者要靠运行时校验(zod 之类)或者契约测试。

要不要加运行时校验?看数据来源。如果接口完全在自己掌控内、后端有完整的单元测试覆盖返回值结构,可以只靠类型。如果数据要经过网关、聚合层、甚至第三方,加一层轻量校验更稳。判断依据不是"要不要严谨",是"错了之后发现得有多晚"。

什么时候不值得上这套

工具不复杂,但它引入了一条新的流水线,有维护成本。判断标准是"接口变动频率"和"参与人数"。

值得上的情况:前后端是不同的人(甚至不同的团队)、接口数量超过几十个、版本迭代频繁、有明确的 CI 流程。这类项目里,一次字段名漂移造成的排查成本,就超过接入成本。

不太值得的情况:一个人既写后端又写前端、接口就十几个且几个月不变、项目处于原型阶段。这种场景下手写 interface 更快,且你对两边都清楚,漂移风险本来就低。

还有个中间方案值得提:只生成数据模型,不生成路径类型。

也就是只用 components['schemas'][...] 那部分,请求函数还是手写。这样能解决最主要的漂移问题(字段名、可选性),又不用处理 paths 那套复杂的类型推导。接入成本低一半,收益大概七成。对多数组队来说,这个性价比更高。

如果要接,按这个顺序

新项目直接接入最省事,老项目硬上会炸——几百个接口一次性生成,类型全是 any 和嵌套展开,前端改一天也改不完,最后不了了之。

老项目的渐进顺序,实测比较顺的是这样:

第一步,只让后端产出 spec,前端不用。 这一步零风险,成本就是一个依赖。先让团队习惯"改接口顺手加 @Operation",并且把 openapi.json 提交进仓库。此时 spec 的价值是文档——比手写 wiki 准,因为它跟着代码走。

第二步,前端只对新增接口用生成的类型。 老接口的 interface 一个都不动。这样新代码是准的,老代码维持现状,没有一次性改造的压力。

第三步,把 CI 校验加在"新增和修改"上。 不要一上来就要求全仓库生成结果一致,那会让所有人在第一次提交时被红叉劝退。可以先只校验 openapi.json 的 diff 有没有被 review,等团队习惯了这个节奏,再要求前端类型同步。

第四步,逐步替换老接口。 什么时候替换?在某个老接口要改的时候顺手换,不专门排期。一个季度下来,改过的接口自然都换完了,没换的就是真的稳定的。

这套顺序的核心是:每一步都能独立产生价值,不需要等全部做完才回本。 反过来,一上来就"全面接入",往往卡在第二步——类型生成出来了,但没人敢用,因为改不动老代码。

契约的三个层次

把这套东西放回更大的视角看,前后端之间的"约定"其实有三个层次。

第一层是文档:写清楚有哪些接口、什么参数、什么返回。这层的执行力最弱,靠人读、靠人记,过时了也没人知道。

第二层是类型:把文档变成可编译的代码,不一致就报错。本文讲的 OpenAPI 生成就是这一层。它比文档强,但只管结构,不管行为。

第三层是测试:契约测试、集成测试,验证"实际行为符合声明"。这一层最重,但只有它能覆盖语义。

大部分团队停在第一层,少数做到第二层,做到第三层的基本都是有专门测试投入的团队。往上一层的成本不是线性的,但收益也不是。 从文档到类型这一步,性价比通常最高——因为它把"人的记性"换成了"编译器的记性"。

而编译器不会忘事,这就是它比人可靠的地方。