# JSON 转 TypeScript 类型：从示例到安全接口的完整流程

> 用真实 JSON 示例生成 TypeScript 接口，正确处理可空字段、可选属性、联合类型和大整数，避免生成看似正确却会运行时出错的类型。

**快速答案:** 用真实接口响应而不是手写示例来生成 TypeScript 类型，然后人工检查可空字段、可选属性、联合类型和数组。生成的类型只描述这一次样本，不代表接口契约；有 OpenAPI 或 JSON Schema 时应该以契约为准，并在运行时做校验。

## 核心要点

- 一个 JSON 样本只能推断出一种结构，可选字段和联合类型需要多个样本或 Schema。
- 生成的接口是起点，不能替代运行时校验。
- 不可信数据先用 unknown，再做类型收窄，不要用 any。
- 日期在 JSON 里是字符串，应该在边界保持字符串，需要时再解析。
- 生成文件应该和 API 客户端放在一起，并纳入代码评审。

---

从 JSON 生成 TypeScript 类型只需要几秒钟，但让这些类型真正安全，需要一轮评审。生成器只能描述它看到的样本，无法知道哪些字段可选、哪些值可以是 null、哪些字符串其实是枚举。

这篇文章给出一套能产出有用类型、同时避开常见陷阱的流程。

## 用真实样本，不要手写示例

不要从手写示例生成。用接口的真实响应，在安全环境中抓取。如果响应包含数组，至少放两个结构不同的元素；如果某个字段有时不存在，要包含缺失该字段的样本。

```json
{
  "id": 1042,
  "email": "dev@example.com",
  "profile": {
    "displayName": "Mina",
    "avatarUrl": null
  },
  "roles": ["admin", "billing"],
  "createdAt": "2026-11-09T10:15:00Z"
}
```

粘贴到 [JSON 转 TypeScript 工具](/tools/json-to-typescript)，典型输出是：

```ts
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`）不是一回事。

```ts
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"`。

```ts
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[]`，这通常是正确的。但要确认接口是否保证至少一个元素或固定长度。固定结构更适合用元组：

```ts
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` 可能丢精度。

```ts
type Order = {
  id: string; // 64 位 ID 在边界优先用字符串
};
```

日期到达时是字符串。在边界保持字符串，在需要日期计算的地方再解析：

```ts
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` 开始收窄：

```ts
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 校验工具](/tools/json-schema) 可以按 Schema 检查数据，[JSON 转 JSON Schema 工具](/tools/json-schema-gen) 可以先从样本生成草稿。

上面的手写守卫故意留了缺口：只检查了 `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 目录。接口变化时，类型和请求代码一起改。

```text
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 时，多抓几个样本对比：

1. 正常响应。
2. 可选字段缺失的响应。
3. 错误响应，结构可能不同。
4. 空数组响应。
5. 大数字或长字符串响应。

这些样本的并集，比单一成功响应能产出更好的类型。

有公开 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 格式化工具](/tools/json-formatter) 可以帮助检查样本，转换工具能给出有用的第一版，真正让类型安全的是评审。

把评审落成一次短验证：重新生成后确认 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 样本生成，并把结果当作需要评审的快照。

## 参考资料

- [TypeScript 手册 - 日常类型](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html)
- [JSON Schema - 理解 JSON Schema](https://json-schema.org/understanding-json-schema/)
- [TypeScript 手册 - 类型收窄](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
- [RFC 8259 - JSON 数据交换格式](https://www.rfc-editor.org/rfc/rfc8259)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/generate-typescript-types-from-json
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: json-to-typescript, json-schema-gen, json-schema, json-formatter