JSON ↔ Schema ↔ TypeScript 三向转换:null、optional、oneOf 怎么对应

· 约 8 分钟 TS Schema 转 TS

JSON、JSON Schema、TypeScript 看似可以互相转换,但三者的类型表达力完全不重合——用样本 JSON 推 Schema 时会丢失 required 信息、用 Schema 生 TS 时表达不了 additionalProperties: false、union 在三者里的语义都不太一样。

理解三者各自能表达什么、不能表达什么,才能避免”自动生成的类型用了一周发现校验失败”。

三者的表达能力对比

能表达JSON 样本JSON SchemaTypeScript
字段名
字段类型(string/number/bool/array/object)
必填 vs 可选✗(看不出业务意图)✓(required 列表)✓(? 标记)
联合类型(A 或 B)✓(oneOf/anyOf)✓(A | B)
整数 vs 浮点✗(统一 number)✓(integer / number)✗(统一 number)
字符串格式约束(email/uuid/date)✓(format)✗(只能 branded type 模拟)
枚举值✗(只能看到样本值)✓(enum)✓(union 字面量)
额外字段是否允许✓(additionalProperties)✗(结构类型宽松)
数值范围✓(minimum/maximum)
字符串长度 / 正则✓(minLength/pattern)
数组长度✓(minItems/maxItems)✗(只能用 tuple 模拟固定长度)
null vs 缺失 vs undefined部分(null 是值)✓(type:[“x”,“null”])✓(null / undefined / optional)
循环引用✓($ref)✓(自引用 interface)
注释 / 文档✓(description)✓(JSDoc)

核心结论

  • JSON → Schema:丢失 required 判定(需要业务知识)、丢失 enum 范围(只看到样本里出现过的值)、丢失整浮区分。
  • Schema → TS:丢失 additionalProperties、丢失数值/字符串/数组的运行时约束、丢失 format 信息。
  • TS → Schema 也存在:丢失 number 的整浮区分(TS 里都是 number)、丢失 string 的 format(TS 里都是 string)。

流程:JSON 样本 → Schema 的关键决策

JSON 样本(多条)

合并字段集合:union of all keys

判定 required:
   ├─ 保守:所有出现过的字段都 required(让用户手删)
   └─ 激进:只有"每条样本都出现"的字段才 required

判定 type:
   ├─ 字段在不同样本里类型不同 → oneOf [type1, type2]
   ├─ 字段值有 null → type: ["原类型", "null"]
   └─ 数组成员类型不一致 → items: oneOf [...]

判定 enum:
   ├─ 字段值集合很小(如 < 10 个不重复值)→ 候选 enum
   └─ 但需要人工确认是否真的封闭

判定 integer vs number:
   └─ 全部样本都是整数 → integer,否则 number

输出 Schema

最常踩的坑

1. required 判定太激进

// 样本 3 条都有 email,工具判定 email required
[{ "name": "A", "email": "a@x" },
 { "name": "B", "email": "b@x" },
 { "name": "C", "email": "c@x" }]

// 但实际业务里第 4 条根本没 email
{ "name": "D" }   // ← 校验报错:missing required field "email"

对策:保守模式默认全 required,让用户对照业务手动移除。激进模式需要至少 20-50 条多样化样本才靠谱。

2. enum 误判

// 样本里 status 只出现 "active" "inactive" 两个值
// 工具可能判定 enum: ["active", "inactive"]

// 但业务还有 "pending" "archived",样本里只是没出现

对策:enum 必须人工确认。少数工具支持”建议 enum”模式——只列候选值,让你勾选哪些真的是 enum。

3. 数组成员推断

// 样本里 items 全是 { id, name } 对象
"items": [{ "id": 1, "name": "A" }]

// 工具推 items: { type: "object", properties: { id, name } }

// 但实际业务里 items 也可能是字符串 ID 数组
"items": ["1", "2", "3"]

对策:多样本覆盖。

流程:Schema → TS 的对应关系

基本类型

JSON SchemaTypeScript
type: "string"string
type: "number"number
type: "integer"number(TS 不区分)
type: "boolean"boolean
type: "null"null
type: "array", items: TT[]
type: "object"{ ... }

null 和 optional

// Schema
{
  "properties": {
    "a": { "type": "string" },                    // 必填,必须 string
    "b": { "type": ["string", "null"] },          // 必填,可为 null
    "c": { "type": "string" }                      // 可选(不在 required 里)
  },
  "required": ["a", "b"]
}

对应的 TypeScript:

interface Foo {
  a: string;            // 必填,非空
  b: string | null;     // 必填,可 null
  c?: string;           // 可选,可省略
}

进一步:如果 c 既可省略又可为 null(常见的 OpenAPI 写法):

"c": { "type": ["string", "null"] }   // 不在 required 里
c?: string | null;

⚠️ 不要生成 c?: string | undefined | null——三种语义混杂会让前端处理代码满是 if (c == null) 的丑陋判断。

联合:oneOf / anyOf / allOf

// oneOf - 严格"恰好一个"
{ "oneOf": [{ "type": "string" }, { "type": "number" }] }
// TS: string | number  (TS 表达不了"恰好一个"约束)

// anyOf - "至少一个"
{ "anyOf": [{ "type": "string" }, { "type": "number" }] }
// TS: string | number

// allOf - "全部满足"
{
  "allOf": [
    { "type": "object", "properties": { "a": { "type": "string" } } },
    { "type": "object", "properties": { "b": { "type": "number" } } }
  ]
}
// TS: { a: string } & { b: number }

TS 表达力的盲区

  • TS 的 union 是宽松的”或”——{a: 1, b: 2} 同时满足两个 schema 也合法。
  • JSON Schema 的 oneOf 是严格的”异或”——同时满足两个就算违反。
  • 这个差异只能靠运行时校验补回——TS 类型层面无法表达。

枚举

{ "enum": ["active", "inactive", "pending"] }

推荐

type Status = "active" | "inactive" | "pending";

不推荐

enum Status {
  Active = "active",
  Inactive = "inactive",
  Pending = "pending"
}

理由:

  • TS enum 编译后生成额外 JS 对象(不能 tree-shake)
  • 数字 enum 有反向映射坑
  • 跨模块 const enum 编译问题
  • union 字面量更接近 JSON 原生

$ref 引用

{
  "definitions": {
    "Address": {
      "type": "object",
      "properties": {
        "city": { "type": "string" },
        "zip": { "type": "string" }
      }
    }
  },
  "properties": {
    "home": { "$ref": "#/definitions/Address" },
    "work": { "$ref": "#/definitions/Address" }
  }
}

好的生成器(quicktype、json-schema-to-typescript):

interface Address {
  city?: string;
  zip?: string;
}

interface User {
  home?: Address;
  work?: Address;
}

糟糕的生成器(每次展开):

interface User {
  home?: { city?: string; zip?: string };
  work?: { city?: string; zip?: string };
}

后者的问题:

  • Address 增加字段时所有引用处都不会同步
  • 类型重复,编译产物大
  • 无法表达”home 和 work 是同一类型”

循环引用

JSON Schema 用 $ref 自引用:

{
  "definitions": {
    "TreeNode": {
      "type": "object",
      "properties": {
        "value": { "type": "string" },
        "children": {
          "type": "array",
          "items": { "$ref": "#/definitions/TreeNode" }
        }
      }
    }
  }
}

TS 完全支持自引用:

interface TreeNode {
  value?: string;
  children?: TreeNode[];
}

⚠️ 生成器必须用 ref 引用,不能展开——否则会无限展开导致栈溢出。

TS 生成 JSON Schema 的反向流程

很多工具支持反向:写 TS 类型,生成 JSON Schema 用于运行时校验。

工具风格备注
typescript-json-schema直接读 .ts 文件,提取 interface老牌,配置稍多
ts-json-schema-generator同上,更轻量typescript-json-schema 的轻量替代
zod不读 TS,写 zod schema → 推断 TS 类型 + 导出 JSON Schema现在最主流
typia编译期生成校验器性能极高,但绑定 ts-patch
valibot类似 zod,更小适合 bundle 敏感场景

zod 的优势(已经是 2025 年事实标准):

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0).max(150),
  email: z.string().email().optional(),
});

type User = z.infer<typeof UserSchema>;   // 自动得到 TS 类型
const result = UserSchema.parse(data);     // 运行时校验

优势

  • 一次定义,TS 类型 + 运行时校验同时得到
  • 不需要”先写 TS 再生成 schema”或”先写 schema 再生成 TS”两步走
  • 表达力比 JSON Schema 更强(可以写自定义校验逻辑)

何时不适合 zod

  • 需要把 schema 输出给非 JS 生态(Java / Python / Go 后端)→ 仍要用 JSON Schema 当中间格式
  • 需要 schema 存数据库(动态表单)→ JSON Schema 更通用
  • LLM 函数调用 / Anthropic / OpenAI tool use 接受 JSON Schema → 用 zodToJsonSchema 转换

OpenAPI / Swagger 场景

如果 API 已经写了 OpenAPI 3.x,不需要单独维护 JSON Schema

openapi: 3.1.0
components:
  schemas:
    User:
      type: object
      required: [id, name]
      properties:
        id: { type: integer }
        name: { type: string }
        email: { type: string, format: email }

生成 TS 客户端的工具链选择

工具输出适合场景
openapi-typescript纯类型(path/method/req/res)配 fetch 自己写客户端
swagger-typescript-api类型 + axios/fetch 客户端一站式
orval类型 + React Query / SWR / Vue Query hooksReact/Vue 项目
kubb类型 + 客户端 + zod schema现代化 monorepo
tsoa反向:从 TS 装饰器生成 OpenAPI后端写 TS,前端拿 OpenAPI

OpenAPI 3.0 vs 3.1 的关键差异(影响 schema 表达):

特性3.03.1
JSON Schema 兼容Draft 5 子集Draft 2020-12 完全兼容
nullable 写法nullable: truetype: [..., "null"]
examples 数组example(单数)examples(多数)

迁移建议:新项目直接用 3.1;老项目 3.0 大量在用,工具链支持都好。

实战决策树

有 JSON 样本,想要 TS 类型?
├─ 一次性场景(拿到 API 响应想快速写类型)
│  └─ json-to-schema → schema-to-ts,或直接 quicktype

├─ 需要持续维护
│  ├─ 后端是 OpenAPI → openapi-typescript / orval
│  ├─ 自己写 schema → zod
│  └─ 跨语言团队 → JSON Schema 当真理源

└─ LLM 函数调用 / tool use
   └─ zod → zodToJsonSchema → 喂给 LLM

常见错误清单

症状原因修复
自动生成的 Schema 校验失败样本太少导致 required 判定错误加大样本量,或保守模式手动调
TS 类型生成后所有字段都 optionalSchema 里没填 required 列表显式写 required,或换激进推断模式
同一个对象在不同地方类型不一致生成器展开 $ref 而不是引用换支持 $ref 抽取的生成器
运行时校验通过但 TS 报错additionalProperties 在 TS 里表达不了用 satisfies / 接受这个差异
enum 改个值前端到处改用了 TS enum 而不是 union重构成 union 字面量
后端改了 Schema 前端没同步没接 CI 自动重新生成类型CI 加一步 pnpm gen-types 然后 git diff
类型有 string | undefined | null自动生成把 optional 和 nullable 混了手动梳理:optional = ?,nullable = | null

工具能帮你完成 80% 的机械转换,但剩下 20% 需要业务知识——哪些字段真的可空、哪些 enum 是封闭的、哪个 $ref 应该提取成共享类型。把生成器当起点而不是终点,配合 CI + 人工 review,才能维持长期可用的类型契约。

❓ 常见问题

从 JSON 样本自动生成的 Schema 准确吗?字段是不是 required 它怎么知道?

判定依据是"在样本中是否出现"——多数工具默认把样本里所有出现过的字段标 required。这就是它的局限:样本只有 3 条记录,每条都有 email 字段,工具会判 email 必填——但实际业务里 email 是可选的,只是这 3 条恰好都填了。两种策略:(1) 保守模式(推荐)——所有字段都标 required,让你手动删;(2) 激进模式——只把"在所有样本里都出现"的字段标 required,遇到缺失就移出 required 列表。实务建议:(1) 至少给 20-50 条多样化样本,覆盖各种情况(有 email / 无 email / email 为 null);(2) 生成后人工 review 一遍 required 列表;(3) 别指望工具能区分"业务上可空"和"样本恰好都填"——这是语义信息,样本里没有。

TypeScript 里的 optional (?) 和 union with undefined 是一回事吗?

不一样field?: string 等价于 field: string | undefined,但多了一层"可省略"语义——TS 编译器允许你完全不写这个 key;而 field: string | undefined 必须写出来,值可以是 undefined。对应到 JSON Schema:(1) field?: stringproperties.field不在 required 列表里;(2) field: string | nullproperties.field.type: ["string", "null"];(3) JSON 里 undefined 不存在——序列化后会消失,所以 JSON Schema 没有"undefined 类型"。陷阱:很多代码生成器把 optional 直接转成 string | undefined | null——三种语义混在一起,TS 类型变得难处理。

JSON Schema 里的 oneOf / anyOf / allOf 区别是什么?应该生成什么 TS 类型?

三者语义不同oneOf(恰好一个)——值必须匹配且只匹配其中一个 schema,匹配多个就算错;TS 对应严格的 union(但 TS 没有"恰好一个"约束,是宽松的"或")。anyOf(至少一个)——值匹配其中至少一个 schema 即可;TS 对应普通 union(A | B | C)。allOf(全部)——值必须同时匹配所有 schema;TS 对应 intersection(A & B & C)。生成 TS 时常见错误:(1) 把 anyOf 也生成 union——丢失"至少一个"约束(运行时校验时还是要 anyOf 校验器);(2) allOf 含矛盾约束(如同字段两个不同类型)会生成 never;(3) oneOf 在 TS 里无法精确表达"恰好一个",需要靠运行时校验补回这个语义。

JSON 里的 number 自动转 Schema 后,怎么区分整数和浮点?

默认看不出来。JSON 规范里 number 是统一类型——11.0 在解析后都是 IEEE 754 double,工具拿到样本只看到 number。JSON Schema 提供两个区分:(1) type: "integer"——只允许整数;(2) type: "number" + multipleOf: 1——等价但 anyOf 下行为略不同。自动推断的策略:(1) 样本全是整数 → 推 integer;(2) 样本有任何浮点 → 推 number;(3) 风险:样本恰好都是整数(如 ID)→ 推成 integer,但实际业务有浮点(如运费 1.5)→ 校验失败。TS 对应:integer 和 number 在 TS 里都是 number 类型——TS 类型系统不区分整浮,只能靠运行时校验或 branded type 模拟(type Integer = number & { __brand: "integer" })。

Schema 里的 $ref 引用,TS 类型生成器怎么处理?

好的生成器会复用——把 $ref 指向的 schema 提取成独立 TS interface / type,引用处用类型名引用而不是展开。$ref: "#/definitions/Address" → 生成 interface Address { ... } + 引用处写 address: Address糟糕的生成器会展开——每次引用都把 Address 字段全部展开复制,结果是 10 个对象都用 Address 时生成 10 份重复字段,且改 Address 时所有副本都不会同步。循环引用的处理:(1) 直接生成 interface Tree { children: Tree[] }——TS 完全支持;(2) 注意避免无限展开——遇到循环必须用类型名引用;(3) 跨文件 $ref 必须用 import 引入对方类型。实务:选支持 $ref 抽取的工具(quicktype / json-schema-to-typescript);少数老工具会展开,要避免。

Schema 里的 enum 应该转成 TS 的 enum 还是 union 字面量?

强烈推荐 union 字面量,不要用 TS enum对比:(1) enum Status { Active = "active", Inactive = "inactive" }——是 TS 独有结构,编译后会生成额外 JS 对象,不能 tree-shake,运行时存在;(2) type Status = "active" | "inactive"——纯类型,编译后消失,零运行时开销。TS enum 的额外坑:(1) 数字 enum 反向映射(Status[0] 能拿到名字)会让你的 enum 在反向也能用——很多人误以为这是字符串;(2) const enum 内联值,但跨模块编译有问题;(3) 国际化枚举值时 enum 改写麻烦——union 直接改字符串。生成器选择:quicktype 默认 union,json-schema-to-typescript 默认 union——已经是社区共识。坚持要 enum 的场景:枚举值带元数据(label / icon / 顺序),但那应该用普通对象 + as const,不应该用 TS enum。

Schema 的 additionalProperties: false 转成 TS 后还能保证"额外字段不允许"吗?

不能。TS 的结构类型系统是宽松的——{ a: 1, b: 2 } 可以赋值给 { a: number },多余字段不报错(除非是字面量直接赋值,触发"excess property check")。Schema 的 additionalProperties: false运行时校验约束,TS 类型系统表达不了这个语义。几个变通方案:(1) 类型上加 [key: string]: never——能阻止额外字段被读取,但不阻止赋值;(2) 用 TS 4.9+ 的 satisfies + 字面量——只在写字面量时报错;(3) 唯一可靠的办法:保留 schema 文件,运行时用 ajv 等校验器校验后再用 TS 类型——TS 负责开发期类型推断,schema 负责运行时严格校验。设计原则:TS 类型生成只是便利——真正的契约是 schema 本身,TS 类型不能取代运行时校验。

我的 API 已经是 OpenAPI 3 了,还需要单独维护 JSON Schema 吗?

不需要——OpenAPI 3.x 内置了 JSON Schema 子集。OpenAPI 的 components.schemas 就是 JSON Schema(OpenAPI 3.0 兼容 JSON Schema Draft 5、3.1 完全兼容 Draft 2020-12)。工具链选择:(1) openapi-typescript——直接从 OpenAPI yaml 生成 TS 类型,包含路径、请求体、响应;(2) swagger-typescript-api——生成 TS 类型 + axios/fetch 客户端;(3) orval / kubb——生成 TS + React Query / SWR hooks。何时需要单独 JSON Schema:(1) 不是 HTTP API(如配置文件、消息队列 payload、AI 函数调用 schema);(2) 需要把 schema 放进数据库(动态表单、流程引擎);(3) 跨多语言生态共享(OpenAPI 偏 HTTP,JSON Schema 通用度更高)。

TS 打开 Schema 转 TS JSON Schema 生成 TypeScript 类型 · 可改根类型名