Schema 与 TypeScript 类型双向同步
结论:类型和 schema 各管一段:一个在编译期,一个在运行时。问题不是选哪个,而是如何让两者自动保持一致。

结论:先定一份事实来源——后端主导选 schema 优先,前端主导选 zod「schema 即类型」——再让另一份由脚本生成并在 CI 比对产物;只有生成器覆盖不到的语义(如品牌类型)才手写。
手写两份定义,就一定会漂移。漂移的代价是「编译通过、运行时 500」。
一、为什么不能只留一份
| 能力 | TypeScript 类型 | JSON Schema |
|---|---|---|
| 编译期检查 | ✓ | ✗ |
| 运行时校验 | ✗(类型被擦除) | ✓ |
| 跨语言使用 | ✗ | ✓(Java/Go/Python 都能读) |
| IDE 补全与重构 | ✓ | 一般 |
| 文档生成 | 一般 | ✓(OpenAPI 生态) |
接口的输入来自网络,永远是 unknown。所以运行时校验绕不过去;而写业务代码时又必须有类型补全。
合理做法是选一个作为事实来源,另一个自动生成。
二、路径 A:schema 优先
适用:后端主导、跨语言、需要对外发布契约。
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* 由 JSON Schema 生成 TypeScript 类型定义。
* @param schemaPath schema 文件路径
* @param outputPath 输出文件路径
* @returns 生成完成的 Promise
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* 由 schema 自动生成,请勿手动修改 */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
生成结果的几个约定值得注意:
required: ["id"]→id: string;未列入required→id?: string;"type": ["string", "null"]→string | null;enum→ 字面量联合类型,比string精确得多;additionalProperties: false影响是否生成索引签名。
还有一个零构建方案 json-schema-to-ts,直接在类型层推导:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
优点是没有生成步骤、类型永远和 schema 同步;缺点是 schema 必须写成 as const,且复杂 $ref 场景编译较慢。
三、路径 B:类型优先
适用:前端主导、用 zod / valibot 这类「schema 即类型」的库。
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** 对外发布给其他语言的契约 */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
这里 zod 同时承担了「类型定义」与「运行时校验」两个角色,JSON Schema 只是导出的产物。 最省心,但要注意两点:
z.infer与生成的 JSON Schema 在default、transform、refine上的语义不完全对应;- 生成的 schema 里可能出现
$ref与definitions,发布前检查是否符合目标规范。
四、反向也要校验
无论选哪条路径,都要加一条 CI 检查:生成的产物与仓库中的文件一致。
# 生成到临时目录后比对,有差异就让 CI 失败
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
没有这一步,三个月后没人知道 api.d.ts 是不是最新。
五、四个高频陷阱
5.1 optional 与 nullable 不是一回事
type A = { name?: string }; // 键可以不存在
type B = { name: string | null }; // 键必须存在,值可为 null
对应到 schema:
{ "properties": { "name": { "type": "string" } } } // 可选
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // 可空
生成器对二者的处理完全不同。契约里必须明确选哪种,否则前端 data.name?.length 会在 null 上崩。
5.2 枚举的演进
type Status = 'pending' | 'paid' | 'refunded';
后端新增一个 'cancelled',前端类型没更新就会走进 default 分支。
枚举类字段应显式处理未知值:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 日期与金额不能直接用 string
schema 里 format: "date-time" 只是注解,TS 里就是 string。
需要更强约束时用「品牌类型」:
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* 把字符串标记为 ISO 时间。
* @param value 已确认格式的字符串
* @returns 品牌化的时间字符串
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 大整数
type: "integer" 在超过 2^53 时与 number 不等价。契约里应写成:
{ "type": "string", "pattern": "^[0-9]+$", "description": "订单号,超过 2^53,必须用字符串" }
六、落地流程建议
- 定契约:先用 schema(或 zod)写出请求/响应结构;
- 生成类型:CI 里生成
.d.ts,提交到仓库,便于 review 差异; - 生成校验器:同一份 schema 编译出 Ajv 校验函数,服务端入口统一调用;
- 生成文档:由 schema 输出 Markdown 或 OpenAPI,避免文档手写过期;
- 回归:接口示例(fixtures)同时跑「类型检查」与「schema 校验」,两边都过才算通过。
七、检查清单
- [ ] 只有一份事实来源,另一份由脚本生成;
- [ ] 生成脚本进 CI,产物有差异即失败;
- [ ]
optional与nullable在契约里明确区分; - [ ] 枚举字段有未知值兜底;
- [ ] 大整数、金额、时间在 schema 与类型里都有显式约定;
- [ ] 生成的类型文件头部写明「请勿手动修改」。
常见问题
如何选择 schema 优先还是类型优先?
后端主导、需要跨语言或对外发布契约时选 schema 优先,用 json-schema-to-typescript 生成类型。前端主导、希望类型与校验一次写好时选 zod 这类「schema 即类型」的库,再导出 JSON Schema。两条路径都要在 CI 里比对生成产物与仓库文件。
为什么手写两份定义一定会漂移?
类型在编译期被擦除,schema 在运行时才生效,两者由不同人、在不同时间修改。没有生成与比对环节时,一份更新、另一份不动,结果就是「编译通过、运行时 500」。
是否应该把生成的类型文件提交到仓库?
应该。提交后 review 能看到契约差异,CI 也能用生成结果与仓库文件比对,有差异即失败。只把生成物留在本地或构建缓存里,三个月后就没人知道它是不是最新。