用真实接口响应而不是手写示例来生成 TypeScript 类型,然后人工检查可空字段、可选属性、联合类型和数组。生成的类型只描述这一次样本,不代表接口契约;有 OpenAPI 或 JSON Schema 时应该以契约为准,并在运行时做校验。
- 一个 JSON 样本只能推断出一种结构,可选字段和联合类型需要多个样本或 Schema。
- 生成的接口是起点,不能替代运行时校验。
- 不可信数据先用 unknown,再做类型收窄,不要用 any。
- 日期在 JSON 里是字符串,应该在边界保持字符串,需要时再解析。
- 生成文件应该和 API 客户端放在一起,并纳入代码评审。
从 JSON 生成 TypeScript 类型只需要几秒钟,但让这些类型真正安全,需要一轮评审。生成器只能描述它看到的样本,无法知道哪些字段可选、哪些值可以是 null、哪些字符串其实是枚举。
这篇文章给出一套能产出有用类型、同时避开常见陷阱的流程。
用真实样本,不要手写示例
不要从手写示例生成。用接口的真实响应,在安全环境中抓取。如果响应包含数组,至少放两个结构不同的元素;如果某个字段有时不存在,要包含缺失该字段的样本。
{
"id": 1042,
"email": "dev@example.com",
"profile": {
"displayName": "Mina",
"avatarUrl": null
},
"roles": ["admin", "billing"],
"createdAt": "2026-11-09T10:15:00Z"
}
粘贴到 JSON 转 TypeScript 工具,典型输出是:
export interface Root {
id: number;
email: string;
profile: Profile;
roles: string[];
createdAt: string;
}
export interface Profile {
displayName: string;
avatarUrl: null;
}
这是一个有效起点,但有两个问题:avatarUrl 被固定成 null,roles 是任意字符串数组,而接口很可能只允许几个固定值。
生成器的原理是遍历已经解析好的值,把观察到的结构“加宽”成类型。它看不到 JSON.parse 丢掉的东西。重复键就是典型:{"role":"user","role":"admin"} 解析后只剩 { role: "admin" },最后一个键获胜,生成器根本没有机会提示冲突。数字在生成之前就已经失真:JSON.parse('{"id":9007199254740993}') 返回 9007199254740992,生成器只是忠实地为四舍五入后的值写类型。所以抓样本时要保存原始响应文本,而不是已经经过有损解析的对象。
| 样本里看到的结构 | 生成器写出的类型 | 线上可能返回的值 |
|---|---|---|
"avatarUrl": null | avatarUrl: null | CDN 图片 URL 字符串 |
| 数组只有一个元素 | roles: string[] | 抓样本之后新增的角色 |
"id": 1042 | id: number | 超过 Number.MAX_SAFE_INTEGER 的 64 位 ID |
"createdAt": "2026-11-09T10:15:00Z" | createdAt: string | 仍然是字符串,永远不会变成 Date |
第一行最常见,但报错点往往不在你以为的地方。类型写着 null 时,组件里根本没法直接写 user.profile.avatarUrl.startsWith("https");为了过编译,开发者会把字段改成 string,或者在调用处做类型断言。等接口对上传过头像的用户返回 URL、对其他用户返回 null,断言照样能编译,运行时却抛 TypeError: Cannot read properties of null (reading 'startsWith')。类型没有对样本撒谎,它只是把样本当成了契约。
检查可空和可选字段
JSON 没有“可选属性”的概念,字段要么存在,要么不存在。TypeScript 里可选属性(?)和可空值(| null)不是一回事。
type User = {
id: number;
nickname?: string; // 可能不存在
avatarUrl: string | null; // 一定存在,但值可能为 null
};
只有一个样本时,无法判断属于哪种情况。应该问接口负责人或查看 Schema。不要为了通过编译就把 null 改成 string,那会掩盖真实的运行时情况。
这三种状态在 JSON 里必须区分开,对应到 TypeScript 也是三种不同的写法:
| JSON 状态 | TypeScript 类型 | 含义 |
|---|---|---|
{"nickname": "Mina"} | nickname: string | 一定存在,且不为 null |
{"nickname": null} | nickname: string | null | 一定存在,值可能为 null |
{} | nickname?: string | 整个字段可能不存在 |
如果 tsconfig.json 打开了 exactOptionalPropertyTypes,编译器还会区分 nickname?: string 和 nickname?: string | undefined。把带 nickname: undefined 的对象字面量赋给前者,会得到 TS2375:Type '{ nickname: undefined; }' is not assignable to type 'User' with 'exactOptionalPropertyTypes: true'. 这个报错很有价值,它迫使边界明确:字段缺失到底用“不写这个键”表示,还是用显式的 undefined 表示。
OpenAPI 用 required 和 nullable: true 表达同样的信息,JSON Schema 则可能用类型数组。有这些关键字时,从契约生成;单个样本最多只能推出表格第一行。
联合类型和枚举
单个样本无法暴露联合类型。如果样本里 status 是 "active",生成器会输出 status: string。但接口可能只允许 "active" | "paused" | "closed"。
type UserStatus = "active" | "paused" | "closed";
type User = {
status: UserStatus;
};
优先使用字符串字面量联合,而不是 enum,除非你需要运行时值。联合类型在编译后会被擦除,产物更小。来自不可信来源的值仍然需要在运行时校验。
契约变化时,联合类型的价值才真正体现。假设有一个函数按 status 分支渲染,往 UserStatus 里加上 "archived" 后,所有没有覆盖新值的 switch 都会在需要补分支的位置报编译错误。如果没有联合类型,新值会掉进 default,页面只是安静地渲染一个空徽章,没有人知道状态漏了。常见做法是在 switch 末尾写 default: { const exhaustive: never = status; return exhaustive; },把漏掉的分支从线上事故变成编译错误。
联合类型也可能用错。有些接口把字段设计成开放的字符串空间,方便服务端向前兼容;客户端把它钉死成今天的几个值,服务端每加一个值都变成客户端的破坏性变更。这种情况下应该保留 string,在边界用白名单校验,遇到未知值记录日志而不是拒绝整个响应。这是产品决策:未知数据到底应该让客户端失败,还是让页面降级?
确实需要运行时值(下拉选项、翻译映射)时,用 as const 的常量对象可以同时得到字面量联合和可遍历的列表,也避开了 enum 的反向映射和数字强转问题。数字 enum 不做运行时校验:JSON 里返回 99,照样可以被断言成 enum 类型,在应用里一路传下去。
数组和元组
单元素数组会生成 Item[],这通常是正确的。但要确认接口是否保证至少一个元素或固定长度。固定结构更适合用元组:
type Coordinate = [latitude: number, longitude: number];
数组可以为空时,Item[] 就是正确的。不要在没有契约保证的情况下加上非空元组。
有两种生成结果需要警惕。空数组 [] 没有提供任何推断依据,有的工具会输出 never[],第一次执行 items.push(user) 就报 Argument of type 'User' is not assignable to parameter of type 'never';有的工具则输出 any[],悄悄关掉了检查。两者都说明样本没有覆盖到这个字段。混合数组 [1, "two", null] 会生成 (number | string | null)[],类型是诚实了,但用起来很痛苦;如果接口实际返回的是几种带标签的对象,应该每种变体各抓一个样本,建成可辨识联合,而不是让原始类型混在一起。
元组也有坑。元组长度是类型的一部分,const pair: [number, number] = [1] 会报 TS2322。可选元素 [number, number?] 和剩余元素 [string, ...number[]] 能表达变长结构,但生成器不会从 JSON 推断出来。固定坐标、成对 ID 这类值,最好手写元组,并在边界补一个运行时长度检查。
日期和数字
JSON 数字可能是整数、浮点数,或者超过 JavaScript 安全整数范围的值。生成器通常把所有数字映射成 number。如果接口使用 64 位 ID,number 可能丢精度。
type Order = {
id: string; // 64 位 ID 在边界优先用字符串
};
日期到达时是字符串。在边界保持字符串,在需要日期计算的地方再解析:
type Order = {
createdAt: string; // ISO 8601
};
const createdAt = new Date(order.createdAt);
解析前先校验日期字符串;new Date("not-a-date") 不会抛错,而是产生一个无效日期。
具体行为值得记牢,因为不同字符串形状的解析规则不一样。new Date("2026-11-09") 按 UTC 零点解析;new Date("2026-11-09T10:15:00") 不带偏移,在现代引擎里按本地时间解析;new Date("2026-11-09T10:15:00Z") 才没有歧义。所以只写日期的字符串,在 UTC 以西的时区会显示成前一天。无效日期不会在构造时爆炸,而是在后面某一步:new Date("not-a-date").toISOString() 抛 RangeError: Invalid time value,安全的前置判断是 Number.isNaN(date.getTime())。日历溢出也不会被拒绝:new Date("2026-02-30") 会滚到 2026-03-02,账单周期里的一个笔误就悄悄变成了另一个月。只要日期会影响金额或合规,就应该把格式化后的年月日和输入再比对一次。
大整数必须在解析之前决策,而不是解析之后。Number.MAX_SAFE_INTEGER 是 9007199254740991,JSON 文本 {"id":9007199254740993} 解析后是 9007199254740992,再多的 TypeScript 也找不回丢掉的那一位。接口如果文档写明 64 位 ID,就在传输类型里保持字符串,只在需要算术的地方用 BigInt(id)。JSON.parse 没有保留整数原文的选项;要么换无损解析器,要么让接口直接返回字符串。
| JSON 里的值 | JSON.parse 结果 | 能安全写成 number 吗 |
|---|---|---|
1042 | 1042 | 可以 |
9007199254740993 | 9007199254740992 | 不可以,精度已丢 |
1e400 | Infinity | 不可以,不符合 JSON 数字语义 |
-0 | -0 | 可以,但 Object.is(-0, 0) 为 false |
1.0 | 1 | 可以,小数形式丢失 |
后两行很少影响业务,但它们解释了为什么生成类型不能当 Schema 用:JSON 数字本身不携带“整数还是浮点”“多少位精度”这些元数据。
不可信数据用 unknown
生成的类型描述的是“期望结构”,不是“已经校验”。当数据来自网络、文件或用户输入时,从 unknown 开始收窄:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as User).id === "number" &&
typeof (value as User).email === "string"
);
}
const parsed: unknown = JSON.parse(text);
if (!isUser(parsed)) throw new Error("用户数据格式不正确");
更复杂的契约应该使用 Schema 校验。JSON Schema 校验工具 可以按 Schema 检查数据,JSON 转 JSON Schema 工具 可以先从样本生成草稿。
上面的手写守卫故意留了缺口:只检查了 id 和 email,没有检查 roles、profile 和日期格式;里面的 as User 断言还会掩盖对象内部的不匹配。真正的守卫应该检查后面真正用到的每个属性,字段是否存在用 Object.hasOwn(value, "email") 判断,并且先排除数组,因为 typeof [] === "object" 也能通过第一层检查。用 Zod 时,safeParse 会直接给出结构化失败:{ success: false, error: { issues: [{ code: "invalid_type", path: ["id"], expected: "number", received: "string" }] } }。日志里要记录 issue 的 path,而不是只写一句 “invalid payload”,否则下一个人还要把同一个字段再排查一遍。
还有一个运行时风险是合并。JSON.parse 不会触发 __proto__ 的 setter,但之后一句 Object.assign(target, parsed) 或递归合并就可能触发。解析出来的对象不可信时,先校验结构,再只复制已知字段;不要把任意 JSON 展开进配置对象或 ORM 模型。
把生成类型放在客户端旁边
生成类型应该和调用它的 API 客户端放在一起,而不是丢进全局 types 目录。接口变化时,类型和请求代码一起改。
src/api/users/
client.ts
types.generated.ts
users.test.ts
在仓库里加一个重新生成命令,并把生成文件的 diff 纳入代码评审。没人评审的生成文件,就是未来的运行时 bug。
有两个流程细节能让评审真正可行。第一,让生成结果确定:锁定生成器版本,对键和接口排序,不要往文件头写时间戳,否则每次运行都产生大量噪音 diff,评审者很快就会直接跳过。第二,在 CI 里用 git diff --exit-code -- src/api/users/types.generated.ts 暴露过期文件;失败说明有人改了样本或生成器,却没提交产物。这两个检查都发现不了服务端单方面加字段,因为生成的类型在编译期并不连接服务端。这个缺口正是下一节契约测试要补的。
| 存放位置 | 适合场景 | 代价 |
|---|---|---|
| API 客户端旁边 | 单一归属,类型和请求一起改 | 多个客户端需要同一模型时会重复 |
| 共享包 | 多个应用消费同一契约 | 版本和发布成本 |
全局 types/ 目录 | 很小的仓库 | 归属不清,容易过期 |
monorepo 里,从已发布的 OpenAPI 文档生成共享包通常边界最清晰;单应用则把文件放在客户端目录,不要过早集中化。
校验契约,而不只是样本
如果接口提供 OpenAPI 或 JSON Schema,优先从契约生成。Schema 包含 required、枚举、格式和约束,这些是样本无法表达的。
没有 Schema 时,多抓几个样本对比:
- 正常响应。
- 可选字段缺失的响应。
- 错误响应,结构可能不同。
- 空数组响应。
- 大数字或长字符串响应。
这些样本的并集,比单一成功响应能产出更好的类型。
有公开 Schema 时,用理解契约的工具生成:OpenAPI 文档用 openapi-typescript,JSON Schema 用 json-schema-to-typescript。它们能把 required、enum、oneOf、additionalProperties 翻译成样本表达不出来的 TypeScript 结构。但 format: date-time 和数值范围仍然不会在运行时生效,不可信输入还是要过校验器。
剩下的风险是 Schema 漂移:文档说字段必填,线上服务却漏返回,生成的类型会非常自信地错下去。补一个契约测试:调用真实接口,用同一份 Schema 校验响应,校验失败就让构建失败。如果 Schema 由 API 团队维护,就把它作为构建产物发布,并锁定客户端生成器版本;构建时从 main 分支现拉 Schema,会让昨天的构建今天无法复现。
评审清单
- 可空字段是否写成
T | null? - 可选字段是否只在确实可能缺失时使用
?? - 枚举是否用字面量联合或运行时校验?
- 大数字 ID 是否保持字符串?
- 日期是否在边界保持字符串?
- 不可信数据是否做了运行时校验?
- 生成文件是否提交,并有重新生成命令?
- 是否有测试能在接口结构变化时失败?
生成的类型是一个样本的快照。把它当作编译器能检查的文档,而不是接口的保证。JSON 格式化工具 可以帮助检查样本,转换工具能给出有用的第一版,真正让类型安全的是评审。
把评审落成一次短验证:重新生成后确认 diff 只包含预期字段;用项目自己的 strict 配置跑 tsc --noEmit;调用真实接口并用 Schema 校验响应;搜索新增类型上的 as 和 any;最后故意改掉 fixture 里的一个字段名,确认契约测试会失败。五项都过,这个类型才和它依据的样本一样可信。
有三种场景不该用生成的类型:只读两个字段的一次性脚本、每天都在变形还没稳定的接口、以及根本不是 JSON 对象的二进制或流式响应。这些情况下,写一个窄的局部类型或运行时解析器,比维护一份生成契约更便宜。还有一种情况是团队已经从协议定义生成了类型客户端(GraphQL、gRPC、Protobuf),此时再用 JSON 生成一套手维护的 TypeScript,只会制造两个迟早分叉的事实来源。
常见问题
JSON 转 TypeScript 工具能推断可选属性吗?
只有看到多个样本,或者提供带 required 列表的 JSON Schema 时才能判断。单个样本无法区分“字段可能不存在”和“字段一定存在”。
生成的类型应该用 any 还是 unknown?
未校验的值用 unknown。any 会关闭类型检查并扩散到整个代码库;unknown 必须先做运行时检查,才能安全地转成具体类型。
JSON 里的 null 怎么处理?
显式写成 T | null,不要默默从联合类型里删掉 null,除非接口保证不会返回。可选和可空是两件事:字段可能存在,但值可以是 null。
为什么日期字段生成出来是 string?
JSON 没有日期类型,日期通常序列化成 ISO 8601 字符串。应该在边界保持字符串,只有在需要日期计算时才转成 Date。
应该从 JSON 生成类型,还是从 OpenAPI 生成?
有 OpenAPI 或 JSON Schema 时优先从契约生成。只有在没有 Schema 时才从 JSON 样本生成,并把结果当作需要评审的快照。