JSON、JSON Schema、TypeScript 看似可以互相转换,但三者的类型表达力完全不重合——用样本 JSON 推 Schema 时会丢失 required 信息、用 Schema 生 TS 时表达不了 additionalProperties: false、union 在三者里的语义都不太一样。
理解三者各自能表达什么、不能表达什么,才能避免”自动生成的类型用了一周发现校验失败”。
三者的表达能力对比
| 能表达 | JSON 样本 | JSON Schema | TypeScript |
|---|---|---|---|
| 字段名 | ✓ | ✓ | ✓ |
| 字段类型(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 Schema | TypeScript |
|---|---|
type: "string" | string |
type: "number" | number |
type: "integer" | number(TS 不区分) |
type: "boolean" | boolean |
type: "null" | null |
type: "array", items: T | T[] |
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 hooks | React/Vue 项目 |
| kubb | 类型 + 客户端 + zod schema | 现代化 monorepo |
| tsoa | 反向:从 TS 装饰器生成 OpenAPI | 后端写 TS,前端拿 OpenAPI |
OpenAPI 3.0 vs 3.1 的关键差异(影响 schema 表达):
| 特性 | 3.0 | 3.1 |
|---|---|---|
| JSON Schema 兼容 | Draft 5 子集 | Draft 2020-12 完全兼容 |
| nullable 写法 | nullable: true | type: [..., "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 类型生成后所有字段都 optional | Schema 里没填 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,才能维持长期可用的类型契约。