一、为什么需要 JSON 转 TypeScript
TypeScript 已成为前端开发的事实标准,但为 API 响应、配置文件、Mock 数据手写 interface 既繁琐又易错:
- API 响应类型缺失:后端文档与实际返回不一致,前端需要根据真实响应反向推断类型
- 配置文件类型提示:本地 JSON 配置文件没有类型定义,编辑器无法自动补全
- Mock 数据复用:单元测试用的 Mock 数据需要类型约束,手写重复劳动
- 第三方 API 探索:阅读未文档化的 API 返回结构,类型定义比 JSON 更清晰
JSON 转 TypeScript 解决这些问题:输入 JSON 数据,输出可粘贴到 .ts 文件的 interface 声明。本工具的核心挑战不在解析 JSON(JSON.parse 即可),而在类型推断——从单个 JSON 值反向构造出合理的类型定义。
二、类型推断的核心:TypeInfo 树
本工具采用「TypeInfo 树」策略:先递归遍历 JSON 值构造一棵类型信息树,再从树生成 TS 代码。相比直接边遍历边生成字符串,TypeInfo 树的优势在于:
- 支持合并:数组多个元素的类型可递归合并为统一类型
- 支持去重:相同结构的对象通过签名比较复用同一 interface
- 支持命名:对象类型在提取阶段分配 interface 名,序列化阶段引用
TypeInfo 节点定义:
interface TypeInfo {
kind: 'string' | 'number' | 'boolean' | 'null' | 'array' | 'object' | 'any';
fields?: FieldInfo[]; // object:字段列表
elem?: TypeInfo; // array:元素类型
union?: TypeInfo[]; // 联合类型候选
signature?: string; // 结构签名(去重用)
interfaceName?: string; // 分配的接口名
}
基本类型的推断直白:null → { kind: 'null' },typeof === 'string' → { kind: 'string' },以此类推。复杂在于数组与对象。
三、数组元素类型合并
JSON 数组的元素类型可能不一致。例如:
[1, "a", true] // number | string | boolean
[{ "a": 1 }, { "a": "x" }] // { a: number | string }
[{ "id": 1, "name": "A" }, { "id": 2 }] // { id: number; name?: string }
本工具的策略是递归合并所有元素类型:
function inferType(value, opts) {
if (Array.isArray(value)) {
if (value.length === 0) return { kind: 'array', elem: { kind: 'any' } };
let elem = inferType(value[0], opts);
for (let i = 1; i < value.length; i++) {
elem = mergeTypeInfo(elem, inferType(value[i], opts), opts);
}
return { kind: 'array', elem };
}
// ...
}
mergeTypeInfo 是核心合并函数,规则如下:
- 同类型:基本类型直接返回;对象类型合并字段(见下文);数组类型递归合并元素
- null 与其他类型:保留为联合(如
string | null),除非关闭keepNull选项 - 不同基本类型:构造联合类型,去重后按签名排序
四、联合类型的去重与排序
联合类型用 { kind: 'any', union: [...] } 表示(借用 any 节点附加 union 字段)。构造联合时需去重与排序,保证输出稳定:
function toUnion(a, b) {
const candidates = collectUnion([a, b]); // 展开嵌套联合
const seen = new Set();
const unique = [];
for (const c of candidates) {
const sig = signatureOf(c);
if (!seen.has(sig)) { seen.add(sig); unique.push(c); }
}
if (unique.length === 1) return unique[0];
unique.sort((x, y) => signatureOf(x).localeCompare(signatureOf(y)));
return { kind: 'any', union: unique };
}
排序保证 [1, "a"] 与 ["a", 1] 生成相同的 (number | string)[],而非 (string | number)[]。这对工具的确定性至关重要——相同输入永远生成相同输出。
五、可选字段检测
当数组中多个对象的字段不一致时,缺失字段应标记为可选(?:)。判定规则:字段在数组所有对象中都出现 → 必选;任一对象缺失 → 可选。
function mergeObjectTypes(objs, opts) {
const fieldMap = new Map(); // key → 类型列表
for (const obj of objs) {
for (const f of obj.fields) {
if (!fieldMap.has(f.key)) fieldMap.set(f.key, []);
fieldMap.get(f.key).push(f.type);
}
}
const fields = [];
for (const [key, types] of fieldMap) {
let merged = types[0];
for (let i = 1; i < types.length; i++) {
merged = mergeTypeInfo(merged, types[i], opts);
}
// types.length < objs.length 表示有对象缺失该字段
const optional = opts.optionalFields && types.length < objs.length;
fields.push({ key, type: merged, optional });
}
return { kind: 'object', fields };
}
注意:单个对象(非数组元素)的字段始终必选,因为 JSON 对象本身定义了完整结构,没有「缺失」概念。可选字段仅适用于数组元素的合并场景。
六、嵌套对象提取为独立 interface
为避免生成过深的嵌套类型(如 { user: { profile: { name: string } } }),本工具将对象值字段提取为独立 interface:
function walk(t, name) {
if (t.kind === 'object' && t.fields) {
const ifaceName = nameObject(t, name); // 分配接口名
const newFields = t.fields.map(f => ({
...f,
type: walk(f.type, name + capitalize(f.key)) // 递归处理字段
}));
return { kind: 'object', fields: newFields, interfaceName: ifaceName };
}
if (t.kind === 'array' && t.elem) {
return { kind: 'array', elem: walk(t.elem, name + 'Item') }; // 数组用 Item 后缀
}
// ...
}
命名规则:字段名首字母大写拼接父级名。例如:
{ user: { name: string } }→interface Root { user: RootUser }+interface RootUser { name: string }{ items: [{ id: 1 }] }→interface Root { items: RootItem[] }+interface RootItem { id: number }
七、interface 去重:结构签名
相同结构的对象应复用同一 interface,避免冗余声明。本工具基于结构签名去重:签名由字段名 + 字段类型签名按字典序拼接。
function signatureOf(t) {
if (t.kind === 'object' && t.fields) {
const parts = t.fields.map(f =>
`${f.key}${f.optional ? '?' : ''}:${signatureOf(f.type)}`
);
return `{${parts.join('|')}}`;
}
if (t.kind === 'array' && t.elem) return `[${signatureOf(t.elem)}]`;
if (t.kind === 'any' && t.union) return t.union.map(signatureOf).sort().join('|');
return t.kind;
}
判定示例:
{a:1}与{a:2}→ 签名均为{a:number}→ 复用同一 interface{a:1, b:2}与{b:3, a:"x"}→ a 类型不同(number vs string)→ 两个 interface{a:1}与{a:1, b:2}→ 签名不同 → 两个 interface
去重在 nameObject 函数中完成:首次出现的结构分配新名并记录签名,后续相同签名直接复用。
八、序列化为 TS 代码
TypeInfo 树生成后,序列化为 TS 代码相对直接:
- interface 声明:
export interface Name { field: type; ... } - 数组类型:
T[]或Array<T>(元素为联合时加括号(string | number)[]) - 联合类型:
a | b | c - 内联对象:空对象
{},无 interface 名时内联 - 键名合法性:非法标识符用
JSON.stringify包裹引号
键名合法性检查:
function sanitizeKey(key) {
if (/^[A-Za-z_$][\w$]*$/.test(key)) return key; // 合法标识符
return JSON.stringify(key); // 非法用引号包裹
}
例如 "my-key"、"2nd"、"名称" 等会被引号包裹为字符串字面量。
九、纯原生 TypeScript 实现要点
- 零依赖:仅用
JSON.parse解析,类型推断、合并、去重、序列化全部自实现,无 Babel/TS Compiler API 依赖 - 不可变合并:
mergeTypeInfo返回新 TypeInfo,不修改入参,避免副作用 - 确定性输出:字段按名排序、联合类型按签名排序,保证相同输入相同输出
- bundle 可控:算法库约 460 行,组件约 240 行,gzip 后体积远低于 200KB 红线
- 选项驱动:根名、export、可选字段、数组风格均可配置,适配不同场景
十、与现有工具的联动
本工具是 JSON 工具矩阵的一员,与其他工具互补:
- JSON 工具:格式化、压缩、校验、树形浏览。典型流程:用 JSON 工具格式化压缩的 API 响应 → 复制到本工具生成类型
- JSONPath 工具:数据查询与提取。可用 JSONPath 提取数组字段 → 用本工具生成该字段的类型
- JSON Schema 工具:运行时数据校验。本工具生成开发期类型定义,JSON Schema 生成运行时校验规则,两者结合实现「编辑器提示 + 运行时校验」双重保障
- 格式化三件套:HTML / CSS / JS 格式化工具。生成的 TS 代码可用 JS 格式化工具二次美化(本工具已按 2 空格缩进输出,通常无需再格式化)
十一、常见陷阱
- 单样本推断偏差:从单个 JSON 样本推断的类型可能不完整。例如
[1, 2]推断为number[],但实际可能含字符串。建议用多样本合并(本工具通过数组元素合并实现),或手动添加| string - null 与 undefined 混淆:JSON 只有
null,无undefined。TS 中null与undefined是不同类型。本工具保留null,如需视为undefined需手动替换 - 空数组推断为 any:
[]无元素可参考,元素类型推断为any。建议提供非空样本,或手动指定元素类型 - 可选字段误判:字段在所有样本中都出现时标记必选,但语义上可能可选(如用户未填写的可选字段)。需结合业务语义调整
- 联合类型膨胀:异构数组(如
[1, "a", true, null, {x:1}])会生成复杂联合类型,可读性差。建议先按类型分组,分别生成类型 - interface 命名冲突:不同结构但同名的字段(如多个
user字段)会生成RootUser、RootItemUser等名称,通常可读,但深层嵌套时可能冗长 - 中文键名:TS 允许中文键名(用引号包裹),但部分 linter 会警告。建议生成后用英文键名替换
十二、局限性与改进方向
本工具的已知局限:
- 不识别字面量类型:
"status": "active"推断为string,而非"active"。如需字面量类型,需用 JSON Schema 或手动调整 - 不做语义推断:
"email": "a@b.com"推断为string,而非Email类型。语义类型需借助 JSON Schema 或自定义标注 - 无递归类型支持:JSON 不支持循环引用,本工具也不处理(
JSON.parse会直接报错) - 无枚举推断:
"color": "red"推断为string,而非enum Color { red }。如需枚举需手动转换 - interface 命名简单:基于字段名拼接,无法生成语义化的类型名(如
User而非RootUser)
改进方向:
- 多样本合并:支持输入多个 JSON 样本,合并为更完整的类型
- 字面量类型推断:字符串值少于 N 个唯一值时推断为字面量联合
- 语义类型识别:识别 email、url、date、uuid 等常见格式,生成 branded type
- JSON Schema 双向转换:从 JSON Schema 生成 TS 类型,从 TS 类型生成 JSON Schema
结语
JSON 转 TypeScript 看似简单,实则涉及类型推断、联合合并、去重、命名等多个复杂问题。本工具通过 TypeInfo 树 + 结构签名去重策略,在零依赖前提下实现了完整的类型推断能力。生成的 interface 可直接用于编辑器类型检查,与 JSON Schema 工具结合可实现开发期 + 运行时的双重类型保障。所有处理在浏览器本地完成,零上传、零追踪,适合处理敏感数据的类型推断需求。