JSON Schema 報錯看不懂:Ajv 錯誤定位與錯誤資訊治理
結論:校驗器給出的是一串機器錯誤,使用者需要的是「哪個輸入框錯了」。本文拆解 Ajv 的 errors 陣列,講清 instancePath 與 schemaPath 的差別,並給出歸類、自訂文案、國際化與前端呈現的完整方案。

結論:先看 instancePath 定位出錯的資料位置,再看 schemaPath 定位觸發的規則;required 與 additionalProperties 要從 params 取回真正的鍵名,再決定展示文案。
校驗器只負責說「哪條規則沒過」,把這句話翻譯成「哪個輸入框錯了」,是使用者的工作。
一、errors 陣列到底給了什麼
Ajv 校驗失敗後不會拋異常,而是把 validate.errors 留給你。預設設定只保留第一條錯誤,開啟 allErrors 後才會給出完整清單。單條錯誤物件只有五個常用欄位,理解它們的語意是後續所有處理的前提:
| 欄位 | 含義 | 範例 |
|---|---|---|
instancePath |
出錯值在資料中的 JSON Pointer | /email |
schemaPath |
失敗關鍵字在 schema 中的位置 | #/properties/email/format |
keyword |
失敗的約束名 | format |
params |
該約束的機器可讀參數 | { "format": "email" } |
message |
英文描述,隨版本變化 | must match format "email" |
一條真實的錯誤長這樣:
[
{
"instancePath": "/email",
"schemaPath": "#/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
注意 message 是給人看的散文,不是穩定的介面。Ajv 升級後措辭可能變化,多語言場景下也沒法直接用,所以不要用正規表示式去解析它,更不要把原文直接回傳給客戶端。
二、instancePath 與 schemaPath 各回答什麼
instancePath 回答「資料裡哪一段錯了」,schemaPath 回答「哪條規則觸發了錯誤」。兩者不對稱,是兩個不同座標系:
instancePath是 RFC 6901 的 JSON Pointer,空字串表示根節點;/items/0/price表示陣列第 0 項的價格欄位;schemaPath以#開頭,指關鍵字在 schema 文件裡的位置,跨檔案$ref時它指向被引用 schema 內部,需要結合$id才能定位到具體檔案;- 鍵名裡含
/或~時,Pointer 會分別轉義為~1與~0,呈現前要還原,否則介面上會出現莫名其妙的符號。
舉個例子,instancePath: "/email" 與 schemaPath: "#/properties/email/format" 同時出現,含義是「根物件的 email 欄位沒有通過 format 校驗」。
最容易踩的坑是 required 與 additionalProperties:它們報錯的 instancePath 指向父物件,不是出問題的那個鍵。
| 關鍵字 | instancePath 指向 |
真正的鍵在哪 |
|---|---|---|
required |
父物件(根錯誤時為空字串) | params.missingProperty |
additionalProperties |
父物件 | params.additionalProperty |
type |
出錯值本身 | — |
enum |
出錯值本身 | params.allowedValues |
把錯誤對映到表單欄位時,必須先處理這兩個特例,否則「缺少 email」會被顯示成「根物件錯誤」,使用者完全不知道該填哪裡。
三、allErrors 與 verbose:完整性與成本
預設的 allErrors: false 讓 Ajv 碰到第一個錯誤就停止。對命令列工具足夠,對表單是災難:使用者改完一個欄位提交,再看到下一個錯誤,來回三次才能填對一張表。
| 設定 | 行為 | 收益 | 代價 |
|---|---|---|---|
allErrors: false |
只報第一條 | 最快、記憶體最小 | 使用者需要反覆提交 |
allErrors: true |
收集全部錯誤 | 一次給出所有問題 | 複雜 schema 下錯誤數量爆炸 |
verbose: true |
錯誤裡附帶 schema 與 data |
便於服務端排查 | 體積成倍增長,絕不能直出 |
messages: false |
去掉 message 欄位 |
減小產物與回應體積 | 失去除錯用的英文線索 |
建議:服務端校驗開 allErrors: true,內部日誌保留完整錯誤;對外回應只取白名單欄位。 verbose 只在排查階段臨時打開,它會把命中的 schema 片段和使用者資料一起塞進錯誤物件,既佔記憶體又可能把敏感資料帶進日誌。
另一個常見問題是錯誤數量。一個 anyOf 分支失敗會連帶產出多個子錯誤,再疊加一條彙總錯誤;oneOf 多分支時更明顯。直接把陣列原樣呈現,使用者會看到同一欄位重複五六條提示。歸類必須在呈現前完成。
四、把錯誤歸類到欄位
歸類要做三件事:把路徑還原成欄位名、把 required 與 additionalProperties 的錯位修正、把同一欄位的多條錯誤合併成一條最具體的提示。
/** 一條可呈現的校驗錯誤 */
interface FieldError {
field: string;
keyword: string;
params: Record<string, unknown>;
}
/**
* 把 Ajv 錯誤陣列歸類到欄位。
* @param errors Ajv 產出的錯誤陣列
* @returns 以欄位路徑為鍵、錯誤清單為值的對映
*/
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
const grouped: Record<string, FieldError[]> = {};
for (const errorItem of errors) {
const keyword: string = errorItem.keyword;
let field: string = errorItem.instancePath;
if (keyword === 'required') {
const parentPath: string = errorItem.instancePath;
field = `${parentPath}/${errorItem.params.missingProperty}`;
} else if (keyword === 'additionalProperties') {
field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
}
const normalizedField = field === '' ? '(root)' : field;
(grouped[normalizedField] ??= []).push({
field: normalizedField,
keyword,
params: errorItem.params ?? {},
});
}
return grouped;
}
合併策略按優先級從高到低:required(必填缺失)> type(型別錯誤)> enum/const > format/pattern > 長度與範圍。同一個欄位只呈現優先級最高的一條,其餘留在日誌裡。原因很簡單:型別錯了的時候,長度提示沒有意義,使用者先改型別即可。
oneOf 與 anyOf 的彙總錯誤(keyword 為 oneOf/anyOf)通常沒有可操作資訊,呈現時應跳過,只保留子分支裡命中具體欄位的那些。
五、自訂訊息:外掛與模板表兩條路
Ajv 本身不提供中文文案,常見做法有兩種。
方案一:ajv-errors 外掛。 它引入 errorMessage 關鍵字,可以在 schema 裡就近寫文案:
{
"properties": {
"email": {
"type": "string",
"format": "email",
"errorMessage": {
"format": "請填寫有效的電子郵件地址",
"type": "電子郵件必須是字串"
}
}
}
}
優點是文案跟著欄位走,schema 即文件;缺點是文案被寫死成一種語言,國際化時要維護多份 schema,而且外掛只在 allErrors: true 時才能替換全部訊息,漏開這個選項會得到中英混雜的結果。
方案二:關鍵字到模板的對映表。 校驗器只負責給 keyword 與 params,文案由應用層生成:
| 關鍵字 | 模板 | 可用參數 |
|---|---|---|
required |
缺少必填欄位:{field} | missingProperty |
type |
型別應為 {type} | type |
minLength |
至少輸入 {limit} 個字元 | limit |
maximum |
不能大於 {limit} | limit |
format |
格式不符合要求:{format} | format |
enum |
只能是允許選項之一 | allowedValues |
pattern |
內容不符合約定格式 | pattern(不要直出) |
方案二更適合需要多語言、需要統一文案風格的產品。它把「校驗」和「表達」徹底分開,schema 不承擔翻譯職責,前端也能複用同一套模板。
六、國際化:訊息不屬於校驗器
把文案放進語言包,是治理錯誤資訊的起點。語言包只存模板與鍵,不存具體欄位名;欄位名由表單設定或介面契約提供,兩相結合才生成最終提示。
/** 校驗錯誤文案模板,鍵為 Ajv 的 keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
required: (params) => `缺少必填欄位:${params.missingProperty}`,
type: (params) => `型別應為 ${params.type}`,
minLength: (params) => `至少輸入 ${params.limit} 個字元`,
maximum: (params) => `不能大於 ${params.limit}`,
format: () => '格式不符合要求',
};
/**
* 產生一條面向使用者的校驗提示。
* @param keyword 失敗的約束名
* @param params 約束參數
* @returns 本地化後的提示文字
*/
function buildMessage(keyword: string, params: Record<string, any>): string {
const template = VALIDATION_TEMPLATES[keyword];
return template ? template(params) : '內容不符合要求';
}
三個注意點:
- 不要拼接句子。中文可以說「電子郵件格式不正確」,英文需要「Email format is invalid」,語序不同,拼字串一定會出洋相;
- 複數與量詞交給 i18n 套件,模板裡不要寫死「個字元」,英文的
1 character與3 characters就不同; - 欄位名也要翻譯。
params.missingProperty是email,介面上應顯示「電子郵件」,兩者的對映放在表單設定裡,而不是塞進語言包。
七、別把內部結構回傳給客戶端
開發階段把 schemaPath、params、message 一起回傳很省事,生產環境這是資訊洩露:allowedValues 會暴露內部列舉,pattern 會暴露校驗正規表示式,schemaPath 會暴露檔案結構與欄位命名,某些場景下等於把介面的弱點交給攻擊者。
對外回應只保留三個欄位:欄位路徑(或表單欄位名)、錯誤碼、使用者可讀文案。錯誤碼用穩定的短標識,別用 keyword 原名,給自己留一層緩衝。
{
"type": "about:blank",
"title": "請求資料校驗失敗",
"status": 422,
"errors": [
{ "field": "email", "code": "invalid_format", "message": "請填寫有效的電子郵件地址" },
{ "field": "items[0].price", "code": "invalid_pattern", "message": "金額必須保留兩位小數" }
]
}
服務端日誌則相反:完整錯誤陣列、請求 id、schema 版本號都要留下,排查時才能回答「到底哪條規則攔的」。日誌裡避免整段列印使用者資料,只保留出錯路徑與關鍵字。
八、前端呈現:從欄位高亮到編輯器定位
錯誤最終要落到介面上,呈現方式取決於場景:
| 場景 | 呈現方式 | 關鍵動作 |
|---|---|---|
| 表單 | 欄位下方紅字 + 首個錯誤自動聚焦 | 路徑對映到表單 name,陣列索引保留 |
| 表格批次匯入 | 列號 + 欄名 + 錯誤摘要,支援匯出 | 把 /items/3/price 還原成第 4 列價格欄 |
| JSON 編輯器 | 行內標記 + 問題面板 | 用解析器把 JSON Pointer 對映到行列 |
| API 除錯工具 | 結構化錯誤清單,保留 schemaPath |
面向開發者,可呈現更多細節 |
三個實作要點:
- 一次只顯示每個欄位的第一條,其餘摺疊成「還有 2 個問題」,避免表單被紅字淹沒;
- 陣列索引要保留在定位裡,但呈現時可去掉,例如「第 4 列的價格」比
/items/3/price好讀; - 編輯器場景需要行列對映。純字串搜尋不可靠,欄位名重複時會定位到錯誤位置,應由解析器給出每個節點的偏移量,再換算成行列。
如果錯誤來自使用者手寫的 JSON,而不是表單,第一步應確認文件本身語法合法。語法錯誤發生在解析階段,根本輪不到 schema 校驗。JSON 解析格式化會直接指出行列,比在 schema 錯誤裡繞圈子快得多。關於錯誤資訊的產生端,即由樣本反推 schema 時的過嚴問題,見自動生成的 JSON Schema 為什麼總在拒絕真實資料;校驗器設定與關鍵字體系見JSON Schema 從入門到工程化。
九、常見問題
為什麼只收到一條錯誤
預設 allErrors 為 false,Ajv 遇到第一個失敗就回傳。表單場景應顯式開啟,否則使用者要反覆提交才能看到全部問題。
required 報錯的路徑為什麼是空的
required 的 instancePath 指向父物件,根物件就是空字串,缺失的鍵名在 params.missingProperty 裡。把兩者拼起來才是完整路徑,直接呈現 instancePath 會得到「根節點錯誤」這種無效提示。
message 能直接呈現給使用者嗎
不建議。它是英文散文,措辭隨版本變化,也沒有國際化能力。更穩妥的做法是按 keyword 與 params 自己生成文案,message 只用於日誌排查。
自訂錯誤訊息為什麼沒生效
先檢查是否開啟了 allErrors: true,ajv-errors 在這個開關關閉時只能替換部分訊息。其次確認 errorMessage 的層級與失敗關鍵字同級,寫錯一層就不會被識別。
生產環境回傳哪些欄位比較安全
只回傳欄位路徑、穩定錯誤碼與使用者文案。schemaPath、params、message 留在服務端日誌,既能排查問題,也不會暴露內部列舉、正規表示式與檔案結構。