用 TypeScript 类型做接口文档,怎么让它跟后端返回保持同步
很多团队都在用 TypeScript 写类型定义,然后通过工具自动生成接口文档。但这套流程有个致命问题:文档生成得再漂亮,只要跟后端实际返回对不上,就是废纸。解决这个问题的核心思路是把类型定义作为唯一的真相来源,通过自动化校验在 CI/CD 里卡住任何不一致,而不是靠人肉沟通或定期 review。
先搞清楚为什么会不一致
我在三个项目里遇到过同样的场景:前端定义了一套 User 接口类型,后端 swagger 文档也标着返回 User 对象,但联调时发现字段名对不上、嵌套结构不一样、或者某个字段的类型从 number 变成了 string。原因无非这么几个:
- 后端改了代码但没更新 swagger 注解,swagger 文档本身就失真了
- 前端手写类型定义时,按自己的理解做了"优化"(比如把可选字段写成必填)
- 接口文档工具(如 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 的 format、pattern 等字段。举个例子,如果你在类型里写了:
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)规范作为契约,但前提是这份契约必须是后端代码自动生成的,不能手写。
具体流程:
- 后端用注解(SpringDoc / Swashbuckle / FastAPI 自带的 OpenAPI 支持)自动生成
openapi.json - 前端用
openapi-typescript从这个 JSON 生成 TypeScript 类型定义 - 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 文件。