JSON Schema 校验怎么写:type、required 与 $ref 的四个常见误用

多数 JSON Schema 失效不是写得太少,而是写错位置:required 塞进属性、用 nullable 代替 type 数组、oneOf 分支重叠。校验器不会报错,坏数据照过。

约 9 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

先定位失效原因

JSON Schema 校验漏掉坏数据,绝大多数不是约束写少了,而是写错了位置或选错了关键字。四个高频误用几乎覆盖了八成事故:把 required 写进属性内部、用 nullable 表达可空、用 oneOf 表达「其中之一」、以为 format 会真的校验邮箱。这些错误有一个共同特征——校验器不会报错,schema 本身是合法的,只是它约束的东西和作者以为的不一样。所以排查思路应该反过来:先拿一份明确非法的数据喂进去,看它是否被拒;如果通过了,问题一定在这四个关键字上。下面的表把每个关键字的作用和易错点对齐,可以当作速查。还有一个更隐蔽的前提问题:schema 只能校验它看得见的东西。如果请求体在校验之前被某层中间件做了类型转换,比如把字符串 "1" 转成数字 1,那么针对字符串写的约束永远不会触发,日志里全是「通过」,数据库里存的却是错的类型。

关键字速查

关键字作用易错点
type限定数据类型,可以写成数组表示多选一写 "nullable": true 完全无效,那是 OpenAPI 3.0 的字段
required对象级关键字,声明哪些属性必须出现,值为字符串数组写进属性内部变成 required: true,校验器按未知关键字忽略
additionalProperties控制 properties 之外的字段是否允许设为 false 后每加一个字段都要同步改 schema 和客户端
$ref / $defs复用子 schema2020-12 用 $defs,draft-07 用 definitions,混用会解析不到目标
oneOf / anyOf / allOf组合多个子 schemaoneOf 要求恰好命中一个,两个都匹配反而校验失败
format语义提示,如 email、date-time、uuid多数实现默认只当注解,ajv v8 需要 ajv-formats 才真正校验
items / prefixItems数组元素约束draft-07 的 items 是统一 schema,2020-12 拆成 items 加 prefixItems

一份能直接用的 schema

user.schema.json(2020-12)
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/user.json",
  "type": "object",
  "required": ["id", "email"],
  "properties": {
    "id": { "type": "integer", "minimum": 1 },
    "email": { "type": "string", "format": "email", "maxLength": 254 },
    "nickname": { "type": ["string", "null"], "maxLength": 32 },
    "roles": {
      "type": "array",
      "items": { "$ref": "#/$defs/role" },
      "uniqueItems": true,
      "default": []
    }
  },
  "additionalProperties": false,
  "$defs": {
    "role": { "type": "string", "enum": ["admin", "editor", "viewer"] }
  }
}

type 可以多选,nullable 不存在

type 的值既可以是一个字符串,也可以是字符串数组,后者表示「满足其中任意一个即可」,所以可空字符串写成 "type": ["string", "null"]。JSON Schema 规范里从来没有 nullable 这个关键字,它来自 OpenAPI 3.0 的扩展,校验器遇到会当作未知关键字跳过——除非你显式配置了禁止未知关键字(例如 ajv 的 strict 模式),否则不会有任何提示。另一个高频误解是 integer 与 number 的边界:number 接受整数和小数,integer 只接受数学意义上的整数,而 1.0 在 JSON Schema 里算整数,因为判断依据是数值本身而不是字面写法。用 number 校验金额时,还要配合 multipleOf 或自己限定小数位,否则 0.1 加 0.2 那类精度问题会一路带进业务逻辑。反向的坑同样存在:把 type 写成 ["integer", "string"] 之后,minimum、multipleOf 这类数值关键字只对数字分支生效,字符串分支直接绕过它们,于是「允许字符串」在实际效果上变成了「允许任意字符串」。多类型字段要么补上 pattern 和 minLength,要么干脆拆成两个字段。

required 与 properties 是两个关键字

无效写法
{
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "required": true,
      "nullable": true
    }
  }
}
// required 在这里是未知关键字,nullable 同样
// 结果:{} 和 {"email": null} 都能通过校验
有效写法
{
  "type": "object",
  "required": ["email"],
  "properties": {
    "email": { "type": ["string", "null"], "minLength": 3 }
  }
}
// required 在对象层级,值是属性名数组
// 注意:required 只要求键存在,null 值仍然合法,要拒绝得靠 type

additionalProperties 的取舍

additionalProperties: false 能挡住多余字段,对内部接口很有价值——它把「客户端悄悄多传了一个参数」这种问题从静默忽略变成显式报错,也让拼写错误(例如 emial)暴露出来。代价是每次加字段都要改三处:schema、服务端 DTO、客户端类型,任何一处漏了就是线上 400。对外公开的 API 建议不要设 false,或者先设成只记录不拒绝,否则老客户端一旦带上新字段就会被打回。另一个容易混淆的关键字是 unevaluatedProperties(2019-09 引入):additionalProperties 只看当前层 properties 和 patternProperties,而 unevaluatedProperties 会跨 allOf、anyOf、if/then 的分支去看哪些属性已经被校验过,组合场景下只有它能挡住漏网的字段。

$ref 与组合关键字

复用子 schema 的关键字在 2020-12 叫 $defs,在 draft-07 叫 definitions,两者语义相同但解析路径不同:#/$defs/address 只在 2020-12 文档里有效,draft-07 文档里必须写 #/definitions/address。$ref 的值是 URI 引用,写 #/$defs/x 是文档内绝对指针;写 ./common.json#/$defs/x 是相对引用,解析时的基准 URI 由最近的 $id 决定,没有 $id 就是文档自身位置——这就是「本地能解析、打包后 404」的常见原因。组合关键字的选择更容易出错:allOf 要求全部满足,用来拼接约束;anyOf 至少满足一个,用来表达宽松的可选形态;oneOf 要求恰好命中一个,两个分支同时匹配反而失败。当分支之间没有真正互斥时(比如两个分支都只约束 amount),一条合法数据会同时命中两边,oneOf 直接判它非法。正确做法是给每个分支加 const 或 enum 的判别字段。实践上还有一条容易踩的:draft-07 里 $ref 会忽略同级的其他关键字,2020-12 才允许 $ref 与 title、description 并存。把 description 写在 $ref 旁边,旧文档里会静默丢失,文档信息就这样没了。组合关键字还有性能面:oneOf 的每个分支都会被完整求值,深递归 schema 在大数组上会明显变慢,能用 if/then 表达的判别逻辑不必用 oneOf 硬凑。

三个容易被忽略的行为

  • format 在多数校验器里是注释性的。2020-12 把它拆成 format-annotation 与 format-assertion 两个词汇表;ajv v8 默认不校验 format,要装 ajv-formats 并注册才生效,其它语言的实现也常常需要显式开启。
  • number 包含整数,integer 不包含小数,但 1.0 视为整数。用 integer 校验订单号时要配 minimum,否则负数和 0 都能过。
  • draft-07 的 items 是「所有元素共用同一个 schema」,2020-12 改成 prefixItems 管位置、items 管剩余元素;additionalItems 在 2020-12 已废弃,老 schema 直接迁移会静默失去元组约束。

一套能长期用的校验流程

把校验放对位置,比把 schema 写全更重要。第一道在边界:请求一进来就校验,不要拖到写库之前;第二道在数据源:从数据库读出的历史数据也按同一份 schema 检查一次,因为约束是后来加的,老数据往往并不合规。错误信息必须带 JSON Pointer 路径,例如 /items/3/price,否则前端只能把整个表单标红。schema 本身要进版本控制,和接口文档一起改,别留在某个人的本地文件里。最后,给校验器准备一组「已知非法」的用例,每次放宽约束都跑一遍——前面那四个关键字的错误,基本都是在放宽约束的那一刻引入的。

常见问题

JSON Schema 里能写 nullable: true 吗?

不能,它来自 OpenAPI 3.0,会被当作未知关键字忽略。可空字段写成 type 数组加 null。

required: true 写在属性里为什么没报错?

因为 required 是对象级关键字,值必须是字符串数组;写在属性内部只是一个未定义的关键字,规范要求实现忽略未知关键字,而不是报错。

oneOf 和 anyOf 到底怎么选?

需要互斥分支(如按 kind 区分支付方式)用 oneOf,并用 const 保证判别字段唯一;只要求「满足其中之一即可」用 anyOf。分支重叠时 oneOf 会拒绝合法数据。

为什么 format: email 没拦住非法邮箱?

多数校验器把 format 当注解。ajv v8 需要额外注册 ajv-formats,其它实现也要显式开启断言模式,否则 format 不产生任何校验。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件