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

結論:先固定 $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。
三個高頻誤區:
required只要求鍵存在,{"name": ""}是合法的;要拒絕空串得加minLength: 1;additionalProperties: false會放大相容問題:客戶端多傳一個欄位就整體失敗,介面演進必須同步升級所有呼叫方;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 判別欄位。