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 留在服务端日志,既能排查问题,也不会暴露内部枚举、正则与文件结构。