JSON Schema 报错时先看实例路径,再看失败的关键字:required 表示缺少属性,type 表示 JSON 类型不符,additionalProperties 表示出现了未声明的字段,format 默认通常只是注解。确认路径对应的嵌套层级,再决定改数据还是改 Schema。
- 先读实例路径,它精确指向出错的值,比错误文案更可靠。
- required、type、enum、pattern 和 additionalProperties 覆盖了大多数校验失败。
- format 默认是注解,不要假设验证器会拒绝非法邮箱或日期。
- 不同 Schema 草案支持的关键字不同,使用前先确认草案与验证器版本。
- 即使有 TypeScript 类型,运行时边界仍然需要 Schema 校验。
JSON Schema 的校验错误通常很精确。错误文案可能很短,但实例路径和失败的关键字已经指出了要检查的值。真正困难的是判断问题出在 Schema 还是数据上。
本文按开发者最常遇到的错误类型,给出定位顺序和修复方式。
先读实例路径
验证器通常返回类似这样的路径:
#/items/0/price: must be number
路径指向实例而不是 Schema。它表示 items 数组中第一个元素的 price 属性没有通过 type 校验。先定位到这个值,再读关键字:
| 关键字 | 检查内容 |
|---|---|
| required | 对象缺少某个必需属性 |
| type | JSON 类型不匹配 |
| enum | 值不在允许范围内 |
| pattern | 字符串不匹配正则 |
| additionalProperties | 对象包含 Schema 未允许的键 |
| minItems / maxItems | 数组元素数量不足或过多 |
| oneOf / anyOf / allOf | 组合 Schema 的某个分支失败 |
涉及组合关键字时,单看错误文案很容易误判,实例路径才是最可靠的锚点。
这条路径本质上是 URI fragment 形式的 JSON Pointer。#/customer/address/zip 逐层经过三个键,#/items/0 选中数组第一个元素。两个转义规则必须记住:键名里的 / 写成 1, 写成 0,所以名为 a/b 的属性会显示成 #/a1b。如果路径只有一个 #,说明整个文档本身没通过校验,常见于根节点的 type 判断。
不同验证器给出的结构不同。Ajv 的 instancePath 不带 #,同时提供 keyword、params 和 schemaPath;Python 的 jsonschema 抛出 ValidationError,absolute_path 是一个 deque,消息类似 '42' is not of type 'number'。日志里应保存这些结构化字段,而不是只存一句消息,因为消息会随验证器版本变化,不适合作为测试断言。
一次返回二十条错误时,先修最浅路径上的第一条再重跑。开启 allErrors 后,后面的错误往往只是第一条的连锁反应,逐条追会让排查绕远路。
required:层级放错了
最常见的错误是把 required 写在根对象上,而属性实际位于嵌套对象中:
{
"type": "object",
"properties": {
"customer": {
"type": "object",
"properties": {
"email": { "type": "string" }
}
}
},
"required": ["email"]
}
这个 Schema 要求根对象直接包含 email,而不是 customer 内部包含 email。正确写法是把 required 放进 customer 的 Schema,根对象只要求 customer:
{
"type": "object",
"properties": {
"customer": {
"type": "object",
"properties": {
"email": { "type": "string" }
},
"required": ["email"]
}
},
"required": ["customer"]
}
看到 required 报错时,先确认路径指向哪个对象,再确认 required 数组是否属于该对象的 Schema。
Ajv 的报错通常是 {instancePath: "/customer", keyword: "required", params: {missingProperty: "email"}},Python 则是 'email' is a required property。要注意 required 不检查值的质量:空字符串、null、0 和 false 都算“存在”。如果这些值不可接受,应叠加 minLength、type 或 not 约束。
required 只对对象生效,实例是数组时会被忽略;它也不会从父 Schema 继承。字段必须出现但允许为空时,不要放进 required,而是写成 "type": ["string", "null"]。否则调用方只能传一个占位值来满足校验,契约反而更脏。
type:值能解析,但类型不对
JSON 有六种值类型:对象、数组、字符串、数字、布尔和 null。Schema 写 number 时,字符串 "42" 会被拒绝;Schema 写 string 时,数字 42 也会被拒绝。
不要为了让校验通过就把类型改成 ["string", "number"],除非接口确实接受两种类型。应该先确定契约:
- 不参与运算的 ID 通常用字符串。
- 数量和金额用数字,并单独决定精度策略。
- 布尔值不要用 "true"、"false" 字符串代替,除非有明确原因。
会自动做类型转换的验证器可能接受错误类型并悄悄转换,这会掩盖契约漂移。API 边界应使用严格校验。
两个细节经常被忽略。integer 和 number 是两个关键字,1.0 因为数学值没有小数部分,会被判定为 integer,1.5 则报 must be integer。另一个是精度:JSON 没有整数类型,JavaScript 用 IEEE 754 双精度解析,超过 9,007,199,254,740,991 的整数在校验器看到之前就已经丢精度。订单号、雪花 ID 这类值应使用字符串,金额用最小货币单位或字符串表示,不要赌 64 位整数能完整穿过 JSON。
开启 coerceTypes 后,"42" 会被原地改成 42,你校验的对象已经不再是收到的对象。信任边界上应关闭类型转换。直接校验内存中的 JavaScript 对象时,还要显式拒绝 NaN 和 Infinity,它们不是合法 JSON,却可能在序列化之前进入验证器。
additionalProperties:出现了意外字段
JSON Schema 中 additionalProperties 的默认值是 true。如果 Schema 没有定义某个属性,也没有限制额外键,验证器会接受它。
{
"type": "object",
"properties": {
"id": { "type": "string" }
},
"additionalProperties": false
}
加上 additionalProperties: false 后,除 id 以外的键都会失败。这能发现拼写错误和意外泄露的字段,但当接口先新增字段、Schema 后更新时,也会立刻报错。
应该有意识地使用这个关键字。如果对象本来就是开放的,应写清文档,并考虑改用 propertyNames 或基于 pattern 的约束。
常见报错是 Ajv 的 must NOT have additional properties,params.additionalProperty 指向具体键名;Python 会写 Additional properties are not allowed ('foo' was unexpected)。被 patternProperties 匹配到的键算作已求值,不会报错。真正隐蔽的坑在 allOf 里:某个分支写了 additionalProperties: false,却只声明自己的属性,于是另一个分支声明的字段被它判为多余。解决办法是在外层使用 2019-09 之后的 unevaluatedProperties: false,或者每个分支重复列出全部允许字段。
是否封口是兼容性决策。禁止未知字段能抓住拼写错误和客户端漂移,但接口新增可选字段时,旧版 Schema 会立刻拒绝新客户端。较稳妥的做法是过渡期内记录未知字段并返回警告头,确认没有调用方依赖后再切换成拒绝。
format 默认只是注解
Schema 可以写:
{ "type": "string", "format": "email" }
很多验证器把 format 当作注解,不会拒绝非法邮箱。部分草案和验证器支持格式断言,另一些需要显式开启选项。
如果邮箱或日期校验很重要,应把 format 与 pattern 结合使用,或在应用代码中验证。除非确认验证器开启了格式断言,否则不要假设非法值已经被拒绝。
2019-09 草案把 format 拆成注解词汇表和断言词汇表,验证器是否真的检查取决于它启用了哪一套。Ajv 需要额外安装 ajv-formats,而且它的 email 规则只是实用主义的形状检查,并非完整 RFC 5322。date-time 要求带时区偏移的 RFC 3339 时间,2026-11-10 10:00:00 会失败,2026-11-10T10:00:00Z 才会通过。
pattern 也有类似陷阱:它使用未加锚点的 ECMA-262 正则,所以 "[0-9]{4}" 能匹配 abc1234xyz。要求整个字符串匹配时应写成 ^[0-9]{4}$。电话号码、身份证号和邮编没有合适的标准 format,应交给专门的库,Schema 里的 format 只作为文档说明。
oneOf、anyOf 与 allOf
组合关键字很强大,也容易被误读:
- anyOf:至少一个分支通过。
- oneOf:恰好一个分支通过。
- allOf:所有分支都通过。
oneOf 最容易产生令人困惑的错误。如果两个分支同时匹配,即使每个分支单独都能通过,实例仍然失败。错误信息可能列出所有分支的失败原因,看起来像是什么都没匹配上。
一个实用做法是用常量字段做区分:
{
"oneOf": [
{
"properties": {
"kind": { "const": "card" },
"last4": { "type": "string", "pattern": "^[0-9]{4}$" }
},
"required": ["kind", "last4"]
},
{
"properties": {
"kind": { "const": "bank" },
"iban": { "type": "string" }
},
"required": ["kind", "iban"]
}
]
}
const 让分支互斥,oneOf 的行为就变得可预测。
识别报错形态能省很多时间。Ajv 会给出 must match exactly one schema in oneOf,并用 params.passingSchemas 标记结果:多个分支同时匹配时是数组,没有任何分支匹配时是 null。Python 在多个分支匹配时提示 is valid under each of ...。报错不会告诉你原本想要哪个分支,只能拿实例逐个分支对照。
最常见的错误是分支里漏写 required。properties 只校验实际出现的键,空对象 {} 能同时满足所有没有 required 或 additionalProperties 的分支,于是 oneOf 在空对象上失败。每个分支都要有 required;分支必须互斥时再加 additionalProperties: false。如果业务并不要求互斥,anyOf 更便宜、错误也更清楚,因为 oneOf 必须把所有分支都算完。
草案差异与 $ref
JSON Schema 有多个草案。unevaluatedProperties、prefixItems、$dynamicRef 等关键字在旧草案中不可用,验证器也可能只实现某个草案的子集。
排查“关键字不起作用”之前,先确认三件事:
- Schema 是否通过 $schema 声明了草案。
- 验证器是否支持该草案。
- 关键字是否被当作未知注解忽略。
$ref 解析是另一个常见故障点。$id 缺失或错误会让引用解析到错误的文档。Schema 应尽量靠近使用它的代码,并在部署前确认每个 $ref 都能解析。
草案要明确固定下来。draft-07 的 $schema 是 http://json-schema.org/draft-07/schema#,复用区叫 definitions;2020-12 使用 https://json-schema.org/draft/2020-12/schema,改叫 $defs。2020-12 把数组形式的 items 移到 prefixItems,dependencies 拆成 dependentRequired 和 dependentSchemas。draft-07 中与 $ref 并列的关键字会被忽略,2019-09 之后才会生效。$id 会改变相对引用的基准 URI,同一段片段嵌在不同位置可能解析出不同结果。
构建时把引用到的 Schema 收进 $defs,不要在请求处理过程中联网抓取;验证器里的网络调用会把延迟、DNS 和可用性变成校验失败。CI 中还应拿元 Schema 校验 Schema 本身。很多“关键字不起作用”最后都归结为 aditionalProperties 这类拼写错误——未知关键字是合法的注解,验证器不会报错。
在边界上校验
Schema 是运行时契约。TypeScript 类型会被擦除,无法单独保护网络边界。
const schema = await loadSchema("order.schema.json");
const result = validator.validate(schema, payload);
if (!result.valid) {
throw new ValidationError(result.errors);
}
需要校验的位置包括:
- 进入业务逻辑之前的请求体。
- 信任第三方 webhook 字段之前。
- 服务启动之前读取的配置文件。
- 迁移之前读取的已存储 JSON。
JSON Schema 验证器可以在浏览器中检查数据与 Schema 是否匹配。JSON 转 JSON Schema 生成器可以从示例生成初稿,OpenAPI 查看器适合查看接口契约中的 Schema 组件。校验前可以先用 JSON 格式化工具检查原始数据。
Schema 应在进程启动时编译一次,而不是每个请求都编译。Ajv 按 Schema 对象缓存编译结果,编译本身才是开销大头,每请求重新读文件会直接拖高延迟。对外接口开启 allErrors,让调用方一次修完多个字段;配置文件则适合快速失败。错误响应里返回 instancePath、keyword 和 params,不要返回 schemaPath 或整份 Schema,后者会泄露内部结构。
校验不能代替资源限制。100 MB 的请求体在 JSON.parse 阶段就会阻塞事件循环,Schema 根本还轮不到执行,因此字节上限必须放在 HTTP 层。如果解析结果会合并进应用对象,应使用无原型对象,或直接拒绝 proto 和 constructor 键;JSON Schema 只把 proto 当作普通属性,挡不住原型污染。
如果契约主要是行为约束(限流、顺序、跨字段算术),载荷是数 MB 的二进制块,或者前后端由同一团队同时发布、生成验证器的维护成本高于收益,那么 JSON Schema 并不是合适工具。此时用带类型解码的解析器或手写边界检查,Schema 只保留作公开 API 的文档。
排查清单
- 读实例路径。
- 确认失败的关键字。
- 检查嵌套层级是否正确。
- 确认 JSON 类型。
- 检查 additionalProperties 与意外字段。
- 检查组合分支。
- 确认草案与验证器支持范围。
- 确认每个 $ref 都能解析。
- 用最小数据复现。
- 把失败用例加入测试。
- 每次只删一个关键字或字段,找到真正改变结果的最小改动。
校验错误不是噪音,它是在告诉你契约与数据在哪里出现了分歧。
常见问题
错误路径 #/items/0/price 是什么意思?
它指向实例中的具体位置:items 数组的第一个元素,以及该元素的 price 属性。应该修改这个位置的值,而不是重写整个文档。路径通常比人类可读的错误描述更有用。
明明写了属性,为什么还提示 required?
检查嵌套层级。required 作用于包含该属性的对象,而不是根对象。如果属性位于嵌套对象内,required 数组必须写在那个嵌套 Schema 里。
JSON Schema 会自动校验 format 吗?
很多验证器默认把 format 当作注解,不会拒绝格式错误的邮箱或日期。部分草案和验证器支持格式断言或需要显式开启。涉及邮箱、日期时间、URI 时应叠加 pattern 或在应用层校验。
oneOf、anyOf 和 allOf 有什么区别?
anyOf 只要一个分支通过即可;oneOf 要求恰好一个分支通过;allOf 要求所有分支都通过。oneOf 适合互斥结构,但多个分支同时匹配时错误信息会非常混乱。
有 TypeScript 类型还需要 JSON Schema 吗?
需要。TypeScript 类型在运行时会被擦除,无法校验网络输入。JSON Schema 可以在运行时验证数据,还能用于文档和代码生成。存在 API 契约时,应以 Schema 作为事实来源。