JSON 工具箱

JSON 转 TypeScript:接口、类型守卫与生成边界

结论:从样例 JSON 生成 interface 只完成了一半:可选字段、null、联合类型、日期与大整数都要人工确认,生成结果本身也不做运行时校验。这篇给出生成、收敛与守卫的完整流程。

进阶约 9 分钟发布于 2026-10-31更新于 2026-11-04
JSON 转 TypeScript 接口与类型守卫封面

结论:先用样例或 schema 生成 interface 省掉打字,再逐个复核可选性与 null、日期与大整数保持 string,最后在外部数据边界加运行时校验;只把生成类型当契约,等于没有校验。

生成器只看得见你贴进去的那一条样例。字段会不会缺、值会不会是 null、日期用什么格式,它都只能猜——猜错的部分要靠人补。

一、生成器到底做了什么

把一段 JSON 贴进 JSON 转 TypeScript,拿到的 interface 是一次形状归纳:它统计出现过的键和值的类型,再选择最窄的可用表示。

{
  "id": 1,
  "email": "ada@example.com",
  "profile": { "nickname": "Ada", "age": 36 },
  "tags": ["admin", "beta"],
  "createdAt": "2026-10-31T08:00:00Z"
}

会得到大致这样的结果:

interface User {
  id: number;
  email: string;
  profile: { nickname: string; age: number };
  tags: string[];
  createdAt: string;
}

这段代码编译通过,但它表达的是一个理想值,不是接口的真实约束。生成器不知道 email 会不会缺失、tags 会不会为空、createdAt 是不是永远带时区。它甚至不知道 id 在别的记录里可能是字符串。

所以正确的预期是:生成器负责省掉打字,人负责补语义。

二、可选字段与 null:? 和 | null 不是一回事

这是生成结果最常被直接沿用、也最容易出事的地方。

TypeScript 写法 含义 对应 JSON
email: string 一定存在,一定是字符串 {"email":"a@b.c"}
email?: string 键可以不存在;存在时不能是 null {} 或 {"email":"a@b.c"}
email: string | null 键一定存在,值可以为 null {"email":null}
email?: string | null 键可以不存在,值也可以为 null 三种都可能

生成器只见过一条样例,通常会输出第一种。如果 API 实际上会返回 null,类型就撒了谎:编译期一切正常,运行时 email.toLowerCase() 直接抛错。

补法有两条:

  1. 拿到多份真实样例,覆盖"字段缺失"和"值为 null"两种情况,再让生成器推断;
  2. 生成后手工把字段改成 ?: T | null,并在代码里真正处理这两种分支。

不要为了省事全部写成 ?: string。可选的意思是"键可能不存在",不是"值可能是空";undefined 和 null 在 JSON.stringify 里的行为也不同(前者被省略,后者保留为 null)。

三、数组元素不齐与联合类型

数组是最容易生成错的结构。三条记录里 tags 分别出现 ["admin"]、[]、["beta", 3],生成器可能给出 string[]、(string | number)[] 或 unknown[],取决于样例。

需要人工判断:

  • 元素类型真的混合:写联合类型 (string | number)[],并在消费端收窄;
  • 只是某条数据脏:修数据,别用 any[] 掩盖;
  • 空数组:生成器推不出元素类型,退化成 never[] 或 any[],必须手工补;
  • 固定长度元组:只有业务确实保证长度时才用 [string, string],否则后续加元素就编译失败。

对象数组还要注意"键不齐":

interface OrderItem {
  sku: string;
  quantity: number;
  discount?: number; // 部分记录没有这个字段
}

把可选性写在元素类型里,而不是给整个数组加 ?。后者表示"数组本身可能不存在",完全是另一个意思。

四、日期、大整数、枚举:JSON 没有的类型

JSON 只有字符串和数字,业务语义全靠约定。生成器一律按字面量推断,需要人工纠正:

JSON 字面量 生成器常见输出 应该改成
"2026-10-31T08:00:00Z" string string 保留在传输层,消费时转 Date;不要直接标 Date
1717075200000 number 毫秒时间戳用 number,并注明单位
"1234567890123456789" string 保持 string,避免 number 丢精度
1 / "1" 混用 number | string 统一成一种,别把联合类型带进业务层
"admin" string 字面量联合 'admin' | 'editor',或枚举常量
true / "true" 混用 boolean | string 修数据源,不要生成这种类型

最关键的一条:JSON 里的日期永远是字符串。把 interface 写成 createdAt: Date 是常见的自欺——JSON.parse 不会创建 Date 对象,运行时拿到的仍是字符串,.getTime() 直接报错。正确做法是传输层用 string,在边界处显式 new Date(value) 并校验 Number.isNaN。

大整数同理。id 超过 2^53 时,JSON.parse 已经丢精度,生成 number 只是把这个错误固化进类型。让后端返回字符串,前端保持字符串,是唯一可靠的方案。

五、生成类型不做运行时校验

TypeScript 的类型在编译后全部擦除。interface 再精确,也拦不住一段 {"id": "x"} 的响应——它只约束你自己写的代码,不约束网络传来的字节。

/**
 * 判断任意值是否为用户对象。
 * @param value 待校验的运行时值
 * @returns 是否满足 User 的最小结构
 */
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const candidate = value as Record<string, unknown>;
  return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}

类型守卫只能覆盖你写进判断的字段。字段一多,手写守卫会变成第二份需要维护的定义,和 interface 一起漂移。

所以实际的取舍是:

方案 运行时校验 类型来源 适合场景
只用 interface 无 手写或生成 内部可信数据、编译期约束
interface + 手写守卫 有 两份,易漂移 字段少、边界清晰
zod / valibot 有 schema 推导类型 外部 API、表单、配置
JSON Schema + 校验器 有 schema 生成类型 跨语言契约、文档化

如果数据来自外部,优先让 schema 成为唯一来源,再用 z.infer 或 json-schema-to-ts 推导类型。这样"校验通过"和"类型正确"是同一件事,不会出现两份定义。更完整的双向同步做法见 Schema 与 TypeScript 类型双向同步。

六、interface 还是 type:别在生成结果上纠结

生成器通常输出 interface,但两者在多数场景可以互换。真正需要区分的是三种能力:

  • 声明合并:interface 可以多次声明并自动合并,type 不行。给第三方类型打补丁时用得上,业务模型反而要避免——合并会让"多出来的字段"悄悄通过编译。
  • 联合与映射:type 能直接写 A | B、Partial<T>、模板字面量;interface 不能。
  • 递归:两者都支持,但复杂递归用 type 更自然。

团队里更实用的规则是:对象形状用 interface,联合、工具类型、函数类型用 type。生成结果保持默认即可,不要在评审里为这个争论,把精力放在可选性和运行时校验上。

七、工具链:三种生成方式的选择

方式 输入 优点 局限
站点 JSON 转 TypeScript 样例 JSON 打开即用,本地处理 只基于当前样例,需人工收敛
quicktype JSON / JSON Schema 多语言输出,CLI 可集成 推断保守,复杂联合类型偏宽
json-schema-to-typescript JSON Schema 契约驱动,可复现 需要先有可靠的 schema

quicktype 的典型用法:

npx quicktype -s json -o user.ts user.json --just-types

--just-types 只输出类型、不生成运行时转换代码,适合已有解析层的项目。若 schema 本身是手写的或由后端产出,json-schema-to-typescript 更合适,因为它尊重 required、enum、additionalProperties 这些约束,而样例推断看不到它们。

schema 生成环节本身也有坑,比如把所有字段都标成必填、把 null 吞成可选,参见 JSON Schema 生成器的常见陷阱。

八、生成之后的落地清单

把生成结果提交前,至少过一遍这五项:

  1. 字段命名统一:userId 与 user_id 不要同时出现,转换层只做一次映射;
  2. 可选性复核:对着真实响应确认哪些字段会缺、哪些会是 null;
  3. 日期与 id 保持字符串:在类型上写清楚单位与格式,消费处显式解析;
  4. 收敛 any 与 unknown:生成器给 any 的地方,要么补类型,要么保留 unknown 并加守卫;
  5. 加运行时校验:外部数据至少校验一次,别指望 interface 拦住坏数据。

如果项目同时使用多种 JSON 库(原生 JSON.parse、superjson、lossless-json),大整数与日期的行为并不一致,选型时一并确认,可参考 JSON 解析库选型。

常见问题

生成器为什么把可选字段都写成了必填?

它只看到一条样例,所有出现过的键都被当成必有。解决办法是提供多份覆盖不同情况的样例,或者从 JSON Schema 生成——schema 里的 required 才是权威来源。

interface 里写 Date 为什么运行时报错?

JSON.parse 只产生字符串、数字、布尔、null、对象和数组,不会构造 Date。类型写 Date 只是编译期的承诺,运行时值仍是字符串。传输层用 string,边界处再转 Date。

生成的类型能直接当校验用吗?

不能。TypeScript 类型在编译后擦除,对运行时数据没有任何约束。外部数据要用 zod、valibot、ajv 之类的校验器,或手写类型守卫。

大整数 id 应该生成 number 还是 string?

只要可能超过 2^53,就必须用 string。JSON.parse 在解析阶段就会丢精度,生成 number 只是把错误写进类型,后续无法补救。

如何选择用样例 JSON 还是 JSON Schema 生成类型?

样例生成打开即用,但只看到一条数据,字段的可选性与枚举都靠猜。schema 驱动能看到 required、enum、additionalProperties 这些约束,结果可复现。已有可靠 schema 时用 json-schema-to-typescript,否则先用 quicktype 或站点工具,再人工收敛。