JSON 转 TypeScript(接口 · 联合类型 · 可选键)— 按你粘的样本推出声明

粘一段 JSON 响应,得到可用的 TypeScript 声明:嵌套对象生成各自的 interface,数组给出元素类型,确实分成几类的记录生成联合类型,只有部分记录才有的键加问号,不是标识符的键名自动加引号。样本证明不了的地方会写在注意事项里,而不是替你猜,你粘贴的内容不会离开这台设备。

声明结果

用一份响应推出来的类型,只是关于这份响应的说法,不是关于你这个接口的说法。所以样本证明不了的地方一律写进注意事项,而不是编一个看起来合理的类型把它填平。

JSON 形状到声明的映射

每种 JSON 会变成什么

你粘进来的东西 得到什么
一个对象 interface Name { … },里面的对象按键路径再生成自己的 interface
键完全一致的记录数组 一个 interface,配上 Name[]
键不一致的记录数组 默认合并成一个 interface,缺的键加 ?;只有当某个键能把种类分开时才拆成联合类型(这就是可以关掉的开关)
字符串 / 数字 / 布尔数组 string[]、number[]、boolean[]
数组的数组 元素类型合并计算,矩阵就是 number[][]
同一个键在不同记录里是不同类型 string | number,两种都还能赋值进去
有的记录里是 null 这个属性加 | null,它和"键可以缺席"不是一回事
有的记录里没有这个键 foo?: string,并计入"可选键"统计
[] 空数组 unknown[] 加一条注意事项:空数组说明不了元素是什么
{} 空对象 Record<string, unknown> 加一条注意事项 — 写成 interface Empty {} 更糟,TypeScript 的 {} 几乎什么都收
一个数字 number。TypeScript 没有整数类型也没有小数类型,只有这一个双精度

? 和 | null 说的是两件事

对 [{"id":1},{"id":2,"note":"hi"}],给出的是:

interface RootItem {
  id: number;
  note?: string;
}

note 只出现在 2 条记录里的 1 条,也就是说这个键可能整个不在 — 这才是 ? 的意思。再看 [{"note":"hi"},{"note":null}]:键每次都在,所以它不是可选的,类型是 note: string | null。把"值可能是 null"当成"键可能不在",就是在对着 null 写 res.note.length 的老办法;你的数据分得开这两件事,它们也就被分开。

把可选那个勾去掉,所有键都会写成必填,于是类型开始要求一些根本不存在的东西 — 注意事项会告诉你它覆盖了多少个这样的键。

一个键能把记录分开的时候

像 [{"type":"dog","bark":true},{"type":"cat","meow":true}] 这样的数组并不是"一个形状加几个可选字段",而是两种形状:合并之后,一条只有 bark、没有 type 的记录也能通过检查。所以先按键集合把记录分组,再看有没有一个键满足"每条记录都有,且同组内取值恒定、组与组之间不同" — 满足就把它当判别键:每种形状各得一个 interface,判别键的类型收窄成字面量(type: "dog")而不是 string。switch (item.type) 能自动收窄,靠的就是这个。

如果键集合不同但没有任何键能把它们分开,硬拆只会得到 RootItem 和 RootItem2 这种谁也说不清是什么的名字,所以改成合并成一个 interface,并在不是每条记录都有的键上加 ?;注意事项会把它看到的键集合原样列出来,比如 {id} vs {id, note}。把开关关掉,连真正的判别键也一律合并;一个数组里超过 6 种形状也不拆,7 个匿名形状组成的联合类型没人读得动。

名字是从你的键名拼出来的

interface 的名字全部来自你粘的键名,跟值的内容无关。data 里的 user_profiles 变成 DataUserProfile。装数组的键在元素类型名上会去掉复数,所以 items 给 RootItem、categories 给 RootCategory;media、address 这类不去尾部的词保留原样并补一个 Item,因为从这些词上硬砍一个 s 一定是错的 — 这是个拼写上的取舍,也是命名字里唯一的一处"猜",它只改标识符,不改类型。

  • 两条路径拼出同一个名字时,后面的加 2、3,注意事项会说是哪两个撞了。
  • 中文、日文、韩文、西里尔字母的键名会原样进入 interface 名,因为 TypeScript 允许它们当标识符字符。
  • 拼出来会以数字开头的名字,前面补一个下划线。
  • 根类型名字那个框是要检查的:留空退回 Root,不是标识符的、或者 TypeScript 不能拿来当声明名的词(interface、type、namespace、string、keyof 这一类)也退回 Root。想试 interface interface {} 的人会撞到语法错误。

不是标识符的键会加引号

{"user-id": 7, "2024": 1, 🎉: true} 会得到 "user-id": number;、"2024": number;,表情符号同样加引号。加引号只针对名字本身,属性照样能用 obj["user-id"] 取到。保留字不加引号:default、type、class 在 interface 里都是合法属性名,需要躲开保留字的只有根类型那个名字。

下面这几件事是浏览器定的:

  • 重复的键。 同一个对象里出现 "a":1 和 "a":2,JSON.parse 只保留后面那个,这是解析器的规则;生成的类型就是 a: number,前一个值不会留下任何痕迹。
  • 键的顺序。 声明里的属性顺序就是 JSON.parse 交回来的顺序,而这个顺序会把像整数的键排到最前面:{"user-id":1,"2024":2} 出来是 "2024" 在前。属性顺序在 TypeScript 里不表达任何含义,所以不跟解析器较劲。
  • 非法输入。 JSON 有问题时,屏幕上引用的是浏览器自己那句报错,包括它的措辞和位置,重写一遍反而看不出是谁说的。

大整数其实已经变了

位数超过一个双精度能装下的 JSON 数字(比 9,007,199,254,740,991 还大的那些),在任何读取方看到它之前通常就已经被抹平了 — JSON.parse 交回的是 IEEE-754 双精度,9007199254740993 到手是 9007199254740992。原始文本会被扫一遍,告诉你有几个数字被改动过、并举一个例子,而类型仍然是 number,因为这就是事实。ID 需要保住每一位的话,它在接口里就该是字符串 — 这条注意事项可以直接拿回去给写接口的人看。

常见问题

Q:生成的东西是合法的 TypeScript 吗? 只有声明 — interface 和 type,没有 import,没有运行时代码,没有装饰器。顺序也不重要:TypeScript 会先收集类型声明,所以接口可以写在用到它的下面。文件是模块就把 export 勾上。

Q:为什么会多出我没有的字段? 一份样本证明不了"不存在"。只有你粘的文本里出现过的东西才会变成类型:样本里从没出现过的键,interface 里也不会有。把你见过的每种记录各粘一条进来。

Q:上限是多少? 输入 1,500,000 字符,遍历 400,000 个值、嵌套 48 层、最多 200 个 interface、一个数组最多拆 6 种形状。越过上限的部分会被标成 unknown 并写进注意事项,而不是硬凑出一个看着很像样的文件。

Q:能拿来转 JSON Schema 或 OpenAPI 吗? 不能。Schema 是另一种输入,规则也不同($ref、oneOf、各种 format),把它当数据读只会得到一个描述 Schema 自身的类型,而不是你的载荷。

Q:我的 JSON 会被上传吗? 解析、分组、拼声明都是在你这台浏览器里跑的循环:没有服务器来替你做这些,你粘贴的内容不会离开这台设备。

微信
微信
支付宝
支付宝