JSON 工具箱

Schema 與 TypeScript 型別雙向同步

結論:型別和 schema 各管一段:一個在編譯期,一個在執行時。問題不是選哪個,而是如何讓兩者自動保持一致。

進階約 11 分鐘發佈於 2026-10-30更新於 2026-10-30
Schema 與 TypeScript 型別雙向同步封面

結論:先定一份事實來源——後端主導選 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,必須用字串" }

六、落地流程建議

  1. 定契約:先用 schema(或 zod)寫出請求/響應結構;
  2. 生成型別:CI 裡生成 .d.ts,提交到倉庫,便於 review 差異;
  3. 生成校驗器:同一份 schema 編譯出 Ajv 校驗函式,服務端入口統一呼叫;
  4. 生成文件:由 schema 輸出 Markdown 或 OpenAPI,避免文件手寫過期;
  5. 迴歸:介面示例(fixtures)同時跑「型別檢查」與「schema 校驗」,兩邊都過才算透過。

七、檢查清單

  • [ ] 只有一份事實來源,另一份由指令碼生成;
  • [ ] 生成指令碼進 CI,產物有差異即失敗;
  • [ ] optional 與 nullable 在契約裡明確區分;
  • [ ] 列舉欄位有未知值兜底;
  • [ ] 大整數、金額、時間在 schema 與型別裡都有顯式約定;
  • [ ] 生成的型別檔案頭部寫明「請勿手動修改」。

常見問題

如何選擇 schema 優先還是型別優先?

後端主導、需要跨語言或對外發布契約時選 schema 優先,用 json-schema-to-typescript 生成型別。前端主導、希望型別與校驗一次寫好時選 zod 這類「schema 即型別」的庫,再匯出 JSON Schema。兩條路徑都要在 CI 裡比對生成產物與倉庫檔案。

為什麼手寫兩份定義一定會漂移?

型別在編譯期被擦除,schema 在執行時才生效,兩者由不同人、在不同時間修改。沒有生成與比對環節時,一份更新、另一份不動,結果就是「編譯透過、執行時 500」。

是否應該把生成的型別檔案提交到倉庫?

應該。提交後 review 能看到契約差異,CI 也能用生成結果與倉庫檔案比對,有差異即失敗。只把生成物留在本機或建置快取裡,三個月後就沒人知道它是不是最新。