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 也能用生成結果與倉庫檔案比對,有差異即失敗。只把生成物留在本機或建置快取裡,三個月後就沒人知道它是不是最新。