JSON 工具箱

JSON Schema 從入門到工程化

結論:關鍵字很多,真正天天用的只有十幾個。把 type/properties/required/$ref 用對,再處理好錯誤資訊,就能覆蓋八成場景。

進階約 14 分鐘發佈於 2026-10-30更新於 2026-10-30
JSON Schema 從入門到工程化封面

結論:先固定 $schema 為 2020-12,再用 $defs + $ref 抽公共結構;只有需要「恰好滿足其一」時才用 oneOf,其餘場景優先判別式或 anyOf。

Schema 不是文件的附屬品,而是可以被執行、被測試、被生成的資產。

一、先搞清版本

版本 $schema 值 狀態 備註
draft-04 http://json-schema.org/draft-04/schema# 已過時 老專案與部分 OpenAPI 2.0 工具
draft-07 http://json-schema.org/draft-07/schema# 廣泛使用 OpenAPI 3.0 的事實基礎
2019-09 https://json-schema.org/draft/2019-09/schema 可用 引入 $defs、註解詞彙
2020-12 https://json-schema.org/draft/2020-12/schema 當前推薦 引入 prefixItems,調整 items 語義

新專案用 2020-12。要注意 items 在 2020-12 裡只管「剩餘元素」,元組式宣告改成了 prefixItems, 這是從 draft-07 遷移時最常見的坑。

二、最小可用 schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/user.json",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "age": { "type": "integer", "minimum": 0, "maximum": 150 },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

這份 schema 覆蓋了最常見的四件事:型別約束、必填、範圍限制、陣列元素約束。

三、關鍵字地圖

3.1 通用

關鍵字 作用 注意
type 型別,可為陣列 "type": ["string", "null"] 表示可空
enum 列舉值 值必須唯一
const 單值約束 適合版本號這類固定值
$ref 引用其他 schema 引用整個 schema,不與被引用物件的兄弟關鍵字合併

3.2 數字

multipleOf、maximum、exclusiveMaximum、minimum、exclusiveMinimum。 注意 draft-04 裡 exclusiveMaximum 是布林值(配合 maximum 使用),draft-07 之後才變成獨立數值關鍵字—— 這讓很多「從舊文件抄來的 schema」在嚴格模式下報錯。

3.3 字串

minLength、maxLength、pattern(ECMA-262 正則)、format(註解)。

minLength 計算的是 Unicode 碼點,不是 UTF-16 單元,也不是位元組。前端用 string.length 校驗會和它不一致。

3.4 陣列

關鍵字 作用
prefixItems 2020-12 的元組式約束,按下標逐個匹配
items 剩餘元素的統一約束(2020-12)
contains + minContains/maxContains 至少/至多包含多少個匹配項
uniqueItems 元素互不相同(深比較,大陣列慎用)
minItems / maxItems 長度約束

3.5 物件

properties、required、patternProperties、additionalProperties、propertyNames、 minProperties、maxProperties、dependentRequired、dependentSchemas。

三個高頻誤區:

  1. required 只要求鍵存在,{"name": ""} 是合法的;要拒絕空串得加 minLength: 1;
  2. additionalProperties: false 會放大相容問題:客戶端多傳一個欄位就整體失敗,介面演進必須同步升級所有呼叫方;
  3. properties 不限制未宣告的鍵,預設是放行的。

3.6 邏輯組合

{
  "oneOf": [
    { "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
    { "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
  ]
}
關鍵字 語義 典型錯誤
allOf 全部滿足 多個分支對同一欄位給出衝突約束
anyOf 至少一個滿足 錯誤資訊難以定位到具體分支
oneOf 恰好一個滿足 資料同時命中兩個分支時報錯,但看起來「明明是對的」
not 不滿足 極少用,錯誤資訊最不友好

oneOf 是最容易寫出「明明合法卻校驗失敗」的關鍵字。若分支約束有重疊(比如都允許額外欄位), 一條資料會命中多個分支,oneOf 直接判失敗。此時應改用 anyOf,或在分支裡用 const 做判別欄位。

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": { "properties": { "kind": { "const": "image" } } },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

判別式(discriminator)寫法比 oneOf 更易讀,也更容易給出好錯誤。

四、拆分與複用

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
    "address": {
      "type": "object",
      "required": ["city"],
      "properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
    }
  },
  "type": "object",
  "required": ["total", "shipTo"],
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "shipTo": { "$ref": "#/$defs/address" }
  }
}
  • $defs 是 2019-09 之後的寫法,draft-07 用 definitions;
  • 跨檔案引用用相對 $id,但生產環境不要依賴執行時拉取遠端 schema,應打包成靜態檔案;
  • 引用後不能在同級追加約束($ref 在有兄弟關鍵字時行為隨版本變化),需要組合時用 allOf。

五、用 Ajv 做執行時校驗

import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({
  allErrors: true,
  strict: true,
  allowUnionTypes: true,
});
addFormats(ajv);

const validateOrder = ajv.compile(orderSchema);

export function assertOrder(input: unknown): void {
  if (validateOrder(input)) {
    return;
  }
  const detail = (validateOrder.errors ?? []).map((errorItem) => ({
    path: errorItem.instancePath || '/',
    keyword: errorItem.keyword,
    message: errorItem.message,
    params: errorItem.params,
  }));
  throw new Error(`訂單資料校驗失敗:${JSON.stringify(detail)}`);
}

要點:

  • compile 只做一次,放到模組頂層;每次請求重新編譯會把 10ms 的校驗拖成幾百毫秒;
  • strict: true 能提前發現拼錯的關鍵字(比如把 minLength 寫成 minlength),值得開;
  • ajv-formats 才會讓 format: "email"、format: "date-time" 真正生效;
  • 2020-12 需要 ajv/dist/2020 入口,別用預設匯出。

六、錯誤資訊怎麼設計

校驗器給的是「哪個關鍵字失敗」,使用者要的是「哪個輸入框錯了」。建議做一層對映:

const FIELD_LABEL: Record<string, string> = {
  '/name': '姓名',
  '/age': '年齡',
  '/tags': '標籤',
};

/**
 * 把 Ajv 錯誤轉換成可直接展示的中文提示。
 * @param errors Ajv 錯誤列表
 * @returns 展示用提示陣列
 */
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
  return errors.map((errorItem) => {
    const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
    if (errorItem.keyword === 'required') {
      const missing = (errorItem.params as { missingProperty: string }).missingProperty;
      return `${label}缺少必填欄位 ${missing}`;
    }
    if (errorItem.keyword === 'minLength') {
      return `${label}長度不足`;
    }
    return `${label}校驗失敗(${errorItem.keyword})`;
  });
}

介面層則保留機器可讀結構,讓前端自己決定展示方式:

{
  "type": "https://bbdrug.com/problems/validation",
  "title": "請求資料校驗失敗",
  "status": 422,
  "errors": [
    { "path": "/items/0/price", "keyword": "pattern", "message": "必須是兩位小數的金額字串" }
  ]
}

七、效能與陷阱

  • 校驗成本與 schema 複雜度、資料規模成正比;oneOf 多分支會導致組合爆炸,幾千條資料時非常明顯;
  • uniqueItems 對陣列做 O(n²) 深比較,大陣列應改成業務側去重;
  • pattern 裡避免災難性回溯寫法((a+)+$),這是 ReDoS 的經典入口;
  • additionalProperties: false 與 allOf 一起用時,子 schema 宣告的欄位會被父級判為「額外」,需要合併成單個 schema 或用 unevaluatedProperties。

八、檢查清單

  • [ ] 固定使用 2020-12,並在檔案頂部寫 $schema;
  • [ ] 用 $defs + $ref 抽公共結構,不復制貼上;
  • [ ] 判別式優先於 oneOf;
  • [ ] 校驗器 compile 結果複用,並開啟嚴格模式;
  • [ ] format 是否強制校驗在文件裡寫清楚;
  • [ ] 錯誤響應同時提供路徑、關鍵字與面向使用者的提示;
  • [ ] schema 進 CI:用 JSON Schema Test Suite 的用例迴歸。

常見問題

如何選擇 JSON Schema 草稿版本?

新專案固定 2020-12,並在檔案頂部寫明 $schema。如果 schema 要進 OpenAPI 3.1,也必須是 2020-12,因為 3.1 直接內嵌這一版;只有仍在維護老工具鏈的專案才繼續用 draft-07。兩者在 items 與 $ref 同級關鍵字上的行為不同,同一套 schema 別混著用。

為什麼 additionalProperties: false 會放大相容問題?

它把未宣告的鍵判為錯誤,客戶端多傳一個欄位就整體失敗。介面演進時所有呼叫方必須同步升級,否則舊客戶端會被新 schema 拒絕。配置檔案類 schema 應顯式關閉額外欄位,公開介面則要權衡相容成本。

是否應該用 oneOf 還是 anyOf?

只有要求資料恰好命中一個分支時才用 oneOf。若分支約束可能同時成立,一條資料會命中多個分支,oneOf 直接判失敗;此時改用 anyOf,或在分支裡加 const 判別欄位。

參考資料

相關文章

Schema 與 TypeScript 型別雙向同步

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

進階約 11 分鐘更新於 2026-10-30

JSON API 資料契約設計要點

六件事一旦上線就很難改:響應包裹、錯誤格式、分頁形態、ID 與時間的表示、null 與預設的語義、版本演進。

進階約 13 分鐘更新於 2026-10-30

additionalProperties 不關,Schema 校驗等於沒校驗

校驗通過不代表資料乾淨。Pydantic 預設忽略未知欄位,Zod 預設剝離,Ajv 預設放行——模型編造的參數、拼錯的鍵都會靜默穿過。本文用實測說明靜默放行的三種形態,並給出關閉物件、控制強轉、逐層收口的完整寫法。

進階約 8 分鐘更新於 2026-11-02