用 TypeScript 类型做接口文档,怎么让它跟后端返回保持同步

很多团队都在用 TypeScript 写类型定义,然后通过工具自动生成接口文档。但这套流程有个致命问题:文档生成得再漂亮,只要跟后端实际返回对不上,就是废纸。解决这个问题的核心思路是把类型定义作为唯一的真相来源,通过自动化校验在 CI/CD 里卡住任何不一致,而不是靠人肉沟通或定期 review。

先搞清楚为什么会不一致

我在三个项目里遇到过同样的场景:前端定义了一套 User 接口类型,后端 swagger 文档也标着返回 User 对象,但联调时发现字段名对不上、嵌套结构不一样、或者某个字段的类型从 number 变成了 string。原因无非这么几个:

  1. 后端改了代码但没更新 swagger 注解,swagger 文档本身就失真了
  2. 前端手写类型定义时,按自己的理解做了"优化"(比如把可选字段写成必填)
  3. 接口文档工具(如 apifox、yapi)里手动维护了一份 schema,三套数据源互相打架

要解决这个问题,必须消灭多余的中间数据源,让类型定义直接跟后端返回做校验,而不是跟文档做校验。

用 JSON Schema 做中间桥梁

TypeScript 类型在运行时是不存在的,所以直接拿类型定义去校验后端返回是做不到的。但我们可以把类型定义编译成 JSON Schema,然后用 JSON Schema 校验器去跑实际响应数据。

具体工具链这样搭:

第一步:从 TypeScript 类型生成 JSON Schema

ts-json-schema-generator 这个库,它能从你的 .d.ts 文件或 .ts 源文件里提取类型,生成标准的 JSON Schema(Draft 7)。安装:

npm install ts-json-schema-generator --save-dev

假设你的类型定义文件 api-types.ts 长这样:

export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: string; // ISO 8601
  metadata?: Record<string, unknown>;
}

export interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

export type GetUserResponse = ApiResponse<User>;

生成 JSON Schema 的命令:

npx ts-json-schema-generator \
  --path 'src/types/api-types.ts' \
  --type 'GetUserResponse' \
  --expose 'all' \
  --jsDoc 'extended' \
  --out 'schemas/get-user-response.json'

这里 --jsDoc 'extended' 参数很重要,它能读取你写在类型上的 JSDoc 注释(比如 @format@pattern@minimum 之类的约束),这些注释会被翻译成 JSON Schema 的 formatpattern 等字段。举个例子,如果你在类型里写了:

export interface User {
  /**
   * @format email
   */
  email: string;
  /**
   * @minimum 1
   */
  id: number;
}

生成的 JSON Schema 里就会有对应的校验规则。这比纯 TypeScript 类型强大多了——TypeScript 只能表达"这是 string",但 JSON Schema 能表达"这是符合邮箱格式的 string"。

第二步:用生成的 JSON Schema 校验实际响应

写一个测试脚本,直接调后端接口,拿返回结果跑校验:

import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import getUserResponseSchema from '../schemas/get-user-response.json';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);

const validate = ajv.compile(getUserResponseSchema);

// 调真实接口
const response = await fetch('https://api.example.com/user/1');
const data = await response.json();

if (!validate(data)) {
  console.error('Schema validation failed:', validate.errors);
  process.exit(1);
}

把这个脚本放进 CI,每次后端部署到测试环境后自动跑一遍,不一致立刻告警。我现在的项目在 GitLab CI 里配了定时任务,每 30 分钟跑一次,三个月里抓住了 4 次后端偷偷改字段的情况。

更进一步:从后端代码直接生成 TypeScript 类型

上面那套方案解决的是"前端定义的类型能不能校验后端返回",但还有个根本问题没解决:类型定义本身是谁来写?如果前端手写,那还是会跟后端的真实意图有偏差。

如果你的后端是 Java(Spring Boot)或者 C#(.NET),可以反过来,从后端的 DTO 类直接生成 TypeScript 类型定义。这样类型定义的源头就是后端代码,而不是前端的手工产物。

以 Java 为例,用 typescript-generator 这个 Maven 插件:

<plugin>
  <groupId>cz.habarta.typescript-generator</groupId>
  <artifactId>typescript-generator-maven-plugin</artifactId>
  <version>3.2.1263</version>
  <configuration>
    <jsonLibrary>jackson2</jsonLibrary>
    <classes>
      <class>com.example.dto.UserDTO</class>
      <class>com.example.dto.ApiResponse</class>
    </classes>
    <outputFile>../frontend/src/types/generated/api-types.ts</outputFile>
    <outputKind>module</outputKind>
  </configuration>
</plugin>

这样每次后端构建时,会自动更新前端的类型定义文件,作为一个 MR 提交到前端仓库。如果后端改了字段名,前端编译直接报错,根本不用等到联调阶段。

但这里有个坑:typescript-generator 默认会把 Java 的 Optional<T> 映射成 T | null,而实际 Jackson 序列化时可能直接省略 null 字段。你需要在配置里显式声明:

<optionalProperties>useLibraryDefinition</optionalProperties>

或者在 Java DTO 上用 @JsonInclude(JsonInclude.Include.NON_NULL) 注解,确保两边语义一致。

如果后端不用 Java,用 OpenAPI 做中间契约

现实中更多的情况是后端语言五花八门,没法统一用代码生成工具。这时候最务实的做法是把 OpenAPI(Swagger)规范作为契约,但前提是这份契约必须是后端代码自动生成的,不能手写

具体流程:

  1. 后端用注解(SpringDoc / Swashbuckle / FastAPI 自带的 OpenAPI 支持)自动生成 openapi.json
  2. 前端用 openapi-typescript 从这个 JSON 生成 TypeScript 类型定义
  3. CI 里加一步:用 openapi-diff 对比新旧版本的 openapi.json,检测 breaking changes
# 生成类型定义
npx openapi-typescript https://api.example.com/v3/openapi.json \
  --output src/types/schema.ts

# 检测破坏性变更
npx openapi-diff \
  https://api-staging.example.com/v3/openapi.json \
  https://api-production.example.com/v3/openapi.json

openapi-diff 能检测出字段被删除、类型变更、必填/可选状态变化等 breaking changes。把这步放进 CI,任何破坏性变更都会在合并前被拦截。

但这里要额外注意一点:openapi-typescript 生成的类型文件里,每个接口的响应类型是精确的。假设后端 swagger 文档里标注 /user/{id} 返回 User,但实际上因为代码 bug 返回了 null,前端拿到类型定义会以为一定是 User,运行时却报错。所以即使从 OpenAPI 生成了类型,前面讲的 JSON Schema 校验方案仍然有必要,它俩解决的是不同层面的问题。

如何处理分页、嵌套泛型、联合类型这些复杂场景

实际业务接口远比 GetUserResponse 复杂。典型的分页响应:

export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

export type ListUsersResponse = ApiResponse<PaginatedResponse<User>>;

ts-json-schema-generator 对泛型的支持在 2022 年的版本里还不稳定,但从 v2.0.1(2023 年 4 月发布)开始,已经能正确展开多层泛型嵌套。如果你还在用老版本,建议升级。

联合类型(union type)的 JSON Schema 转换有时会产生过于宽松的校验。比如:

export type Status = 'pending' | 'approved' | 'rejected';

生成的 JSON Schema 是 { "type": "string", "enum": ["pending", "approved", "rejected"] },这是正确的。但如果是:

export type Result = { success: true; data: User } | { success: false; error: string };

生成的 JSON Schema 会用 oneOf,校验器能正确区分两种情况。但如果你用 Ajv 的默认配置,oneOf 的校验性能比 anyOf 差不少。建议对大型响应(> 10KB)用 ajv-keywords 插件做编译优化,或者把 oneOf 改成 anyOf(前提是你的业务逻辑能容忍这种宽松)。

常见问题

我们后端给的 swagger 文档本身就不准,怎么办?

这是最常见的情况。swagger 不准通常是因为后端只在写代码初期加了注解,后面改逻辑时忘了同步更新。解决这个问题的唯一办法是把 swagger 文档的准确性纳入后端的 CI 检查。具体做法:在测试环境跑集成测试,用 swagger-request-validator 这类工具拦截请求和响应,如果实际返回跟 swagger 定义不一致,测试直接失败。后端只有在 swagger 文档被强制校验的情况下,才会养成维护注解的习惯。

JSON Schema 校验有性能开销,能直接在生产环境用吗?

不建议在请求链路里做同步校验。Ajv 编译一次 schema 要花 50-200ms(视复杂度而定),但编译后的校验函数执行很快,1KB 左右的响应体校验耗时在 1-3ms。更好的做法是在 CI、E2E 测试、或者独立的监控脚本里跑校验,不要把它嵌入到业务代码的中间件里。如果一定要在线校验,用 Ajv 的 standalone 模式把校验函数预编译成独立的 JS 文件,避免运行时编译开销。

类型定义里有很多可选字段,后端返回时直接省略了这些字段,Ajv 会报错吗?

JSON Schema 默认允许缺少非必填字段,前提是你的 schema 里正确标记了 required 列表。ts-json-schema-generator 在转换时,会把 TypeScript 里的非可选字段(没有 ? 标记的)放进 required,可选字段不会放。所以只要你的类型定义里正确使用了可选标记,生成的 schema 就不会在缺字段时报错。但要注意:如果你用 Partial<User> 或者 Pick<User, 'id' | 'name'> 这类工具类型,生成器从 v2.0.1 开始能正确处理,老版本可能会把所有字段都标成必填,建议升级后验证一下生成的 schema 文件。