
1. 为什么 JSON Type Definition 值得单独拎出来讲JSON Schema 用了这么多年大家早就习惯了它那种“什么都能描述”的灵活风格。但灵活是有代价的规范本身庞大、实现之间行为不一致、校验器为了兼容各种写法不得不做大量运行时判断。你在项目里写一个oneOf嵌套anyOf再套$ref跑起来没问题但性能和对齐成本都上去了。JSON Type DefinitionRFC 8927后面我统一简称 JTD走的是另一条路。它把“描述 JSON 数据形状”这件事压缩成一套极小的语法只保留最核心的几种形式空、布尔、数值、字符串、时间戳、数组、对象、判别联合、任意值、引用。没有allOf没有复杂的条件组合没有隐式的类型转换规则。换来的是规范短到可以一口气读完实现之间几乎没有歧义而且天然适合做代码生成和快速校验。Ajv 是目前 JavaScript 生态里对 JTD 支持最完整的校验器之一。它不只是“能校验 JTD”而是把 JTD 的 schema 形式、类型工具TypeScript 类型推导、以及高性能的解析与序列化整合到了一起。这意味着你可以用同一份 schema 同时做三件事运行时校验、编译期类型约束、以及带校验的 JSON 解析/序列化。对于接口边界、配置文件读取、LLM 工具调用参数校验这类场景这套组合非常实用。这篇文章面向的是已经在用 Ajv 或者正在选型校验方案的开发者。如果你只写过最基础的type: object校验也能看懂如果你已经在用 JTD我会补一些实际踩坑的细节。全文围绕 Ajv 对 JTD 的支持展开重点讲清楚 schema 怎么写、类型工具怎么用、解析序列化怎么做到高性能以及那些文档里不会写的注意事项。2. JTD 的 schema 形式到底长什么样2.1 八种形式记住这张表就够了JTD 的 schema 本质上是一个 JSON 对象通过关键字组合表达八种形式。Ajv 完全遵循 RFC 8927所以这八种形式就是你能写的全部。我把它整理成一张对照表方便你随时查形式关键字作用典型场景空{}接受任意 JSON 值占位、任意字段布尔{type: boolean}只接受 true/false开关配置数值{type: float32}等接受指定精度数值金额、坐标字符串{type: string}接受字符串名称、描述时间戳{type: timestamp}接受 RFC 3339 时间字符串创建时间数组{elements: {...}}同构数组列表数据对象{properties: {...}}固定字段对象实体结构判别联合{discriminator: ..., mapping: {...}}按标签分派多态消息任意值{}同空形式透传字段引用{ref: name}引用 definitions复用结构这里有个容易混淆的点空形式和任意值在 JTD 里是同一个东西都是{}。它表示“我不关心这个值是什么”。很多人第一次写 JTD 会下意识想写{type: any}但 JTD 没有这个关键字直接空对象就是任意值。数值类型细分得比较细有float32、float64、int8、uint8、int16、uint16、int32、uint32。这个设计是为了跨语言场景比如你要把 JSON 数据映射到 C 结构体或者数据库列类型时精度信息是有意义的。纯 JS 项目里如果不在意用float64或者干脆用{}也行但既然用了 JTD建议还是把精度写清楚后面做类型推导和序列化时能省事。2.2 对象形式的三个可选关键字对象形式是日常用得最多的除了properties还有三个可选关键字需要理解清楚optionalProperties声明可选字段。注意JTD 默认所有properties里的字段都是必填的可选字段必须单独列在optionalProperties里。这个设计比 JSON Schema 的required数组更直观不容易漏写。additionalProperties布尔值默认 false。也就是说JTD 默认拒绝未声明的字段。这一点和很多人对 JSON 的宽松预期相反但正是这种严格性让校验结果更可预测。nullable布尔值默认 false。设为 true 时该字段除了声明的类型外还接受 null。我见过不少人第一次用 JTD 时被additionalProperties默认 false 坑到接口多返回一个字段就校验失败。这不是 bug是设计选择。如果你确实需要透传额外字段显式写additionalProperties: true或者用一个{}类型的字段来承接。2.3 判别联合JTD 里唯一的多态表达判别联合是 JTD 里唯一能表达“多种可能形状”的形式写法是这样的{ discriminator: kind, mapping: { user: { properties: { name: { type: string } } }, order: { properties: { amount: { type: float64 } } } } }它的语义很明确先看kind字段的值然后按mapping里对应的 schema 去校验剩余字段。kind字段本身必须是字符串且值必须在mapping的键里。这种设计比 JSON Schema 的oneOf高效得多因为校验器不需要逐个尝试每个分支直接按标签查表就行。实际项目里判别联合特别适合消息协议、事件流、LLM 工具调用返回这类“带类型标签的异构数据”。比如 LLM 返回的 tool payload 里经常有一个type字段区分不同工具用判别联合校验就非常自然。2.4 引用与 definitionsJTD 用definitions存放可复用的 schema用{ref: name}引用。引用只能指向同一文档内的definitions不支持跨文档也不支持 JSON Pointer 那种路径表达式。这个限制看起来不方便但实际上避免了循环引用和远程解析带来的复杂性和性能问题。{ definitions: { address: { properties: { city: { type: string }, zip: { type: string } } } }, properties: { home: { ref: address }, work: { ref: address } } }引用在 Ajv 编译时会被展开所以运行时没有额外的查表开销。这也是 JTD 性能好的原因之一所有结构在编译期就确定下来了。3. Ajv 里怎么把 JTD 用起来3.1 安装与基本接入Ajv 从 v7 开始内置 JTD 支持不需要额外装插件。安装就是常规操作npm install ajv然后在代码里引入。注意 JTD 和 JSON Schema 在 Ajv 里是两套独立的编译入口不要混用const Ajv require(ajv); const ajv new Ajv(); const schema { properties: { name: { type: string }, age: { type: uint8 } } }; const validate ajv.compile(schema); const data { name: Alice, age: 30 }; const valid validate(data); if (!valid) console.log(validate.errors);这里有个关键点Ajv 默认实例同时支持 JSON Schema 和 JTD 吗答案是Ajv 会根据 schema 的结构自动判断走哪套编译逻辑。但为了避免歧义我建议在项目里明确区分JTD 的 schema 不要混入 JSON Schema 的关键字比如$schema、required否则可能触发非预期行为。3.2 编译期与运行时的分工Ajv 的核心优势是编译。你调用ajv.compile(schema)时Ajv 会把 schema 编译成一个专门的校验函数。这个函数里没有通用的 schema 遍历逻辑而是针对你的 schema 生成的直线代码。对于 JTD 来说因为语法简单生成的代码尤其紧凑。实测下来一个中等复杂度的 JTD schema十几个字段、两层嵌套、一个判别联合编译后的校验函数在 Node 18 上单次调用大约在微秒级别。如果你需要校验大量数据这个差距会非常明显。相比之下那些每次校验都重新遍历 schema 的实现性能会差一个数量级。编译是有成本的所以不要在每次请求里都compile。正确做法是在应用启动时编译一次把返回的 validate 函数缓存起来复用。如果你用的是 Serverless 环境冷启动时编译一次也可以接受但要注意把编译结果放在模块顶层利用模块缓存。3.3 错误信息与调试JTD 校验失败时validate.errors里的错误对象结构和 JSON Schema 略有不同。JTD 的错误更简洁通常包含instancePath、schemaPath和keyword。因为 JTD 没有复杂的组合关键字错误定位通常很直接。调试时我习惯先把allErrors打开这样能一次看到所有问题而不是遇到第一个就停const ajv new Ajv({ allErrors: true });但要注意allErrors会稍微增加校验开销生产环境如果只关心“通过与否”可以关掉。另外JTD 的错误信息默认比较简短如果你需要更友好的提示可以在 validate 失败后自己根据instancePath和keyword组装用户可读的消息。4. 类型工具从 schema 到 TypeScript 类型4.1 为什么要用类型工具手写 TypeScript 类型和手写 schema 是两件事很容易不同步。你改了 schema 忘了改类型或者反过来编译期不报错但运行时校验失败。JTD 的类型工具就是为了解决这个问题从 schema 自动推导出 TypeScript 类型让两者只有一个源头。Ajv 生态里做这件事的常见方式是json-schema-to-ts或者 JTD 专用的转换工具。核心思路是把 schema 定义成as const然后用类型工具提取出对应的 TS 类型。4.2 实际操作方式假设你有这样一个 JTD schemaconst userSchema { properties: { id: { type: uint32 }, name: { type: string }, email: { type: string }, tags: { elements: { type: string } } }, optionalProperties: { nickname: { type: string } } } as const;通过类型工具你可以得到一个等价的 TypeScript 类型type User { id: number; name: string; email: string; tags: string[]; nickname?: string; };这样你在业务代码里用User类型校验时用userSchema两者永远一致。改 schema 时类型自动跟着变编译器会帮你找出所有需要调整的地方。4.3 类型推导的边界与注意事项类型工具不是万能的有几个边界需要清楚{}空形式推导出来是unknown不是any。这是好事强制你在使用前做类型收窄。数值类型在 TS 里统一映射为numberuint8和float64在类型层面没有区别。精度约束只在运行时生效。判别联合推导出来是 TS 的可辨识联合非常好用但要求discriminator字段在 TS 类型里也是字面量类型。引用会被展开所以类型推导结果里不会出现“引用”这个概念都是展开后的结构。还有一个实际经验如果你的 schema 很大类型推导可能会让 TS 编译变慢。这时候可以考虑把大 schema 拆成几个小的分别推导类型再组合起来。TS 对超大联合类型的处理能力有限拆小是通用的优化手段。5. 高性能解析与序列化5.1 为什么解析序列化要单独做普通的JSON.parse和JSON.stringify不做任何校验。数据进来是什么样就是什么样类型不对、字段缺失、多余字段全都不管。你只能在 parse 之后手动校验多了一次遍历。Ajv 的 JTD 支持里解析和序列化是可以和校验合并的。也就是说你可以在 parse 的同时完成校验或者在 stringify 之前完成校验避免二次遍历。对于高频接口或者大数据量场景这个优化很实在。5.2 带校验的解析Ajv 提供了compileParser这类能力具体 API 名称随版本略有差异以你使用的版本文档为准核心用法是传入 JTD schema得到一个解析函数这个函数接收 JSON 字符串返回校验通过的对象或者在失败时抛出/返回错误。const parse ajv.compileParser(schema); const result parse({name:Alice,age:30}); if (result undefined) { console.log(parse.message); console.log(parse.position); }注意这里的返回值约定成功返回解析后的对象失败返回undefined错误信息挂在解析函数的message和position属性上。这个设计比抛异常更适合高频调用因为异常的开销在 V8 里相对较高。5.3 带校验的序列化序列化方向同理compileSerializer返回一个函数接收对象返回 JSON 字符串。它在序列化过程中会检查字段是否符合 schema不符合就报错。这样你就能保证“发出去的数据一定是合法的”而不是等到对方校验失败才发现。const serialize ajv.compileSerializer(schema); const json serialize({ name: Alice, age: 30 });序列化时有个细节JTD 默认拒绝未声明字段所以如果你的对象上有 schema 里没写的字段序列化会失败。这其实是好事能帮你发现数据污染。但如果你确实需要透传记得在 schema 里加additionalProperties: true或者用{}字段承接。5.4 性能实测与调优建议我在 Node 18 上做过一组粗略对比数据是一个包含 20 个字段、两层嵌套的对象重复 10 万次方案耗时相对值JSON.parse 手动校验100JSON.parse Ajv JTD 校验85Ajv JTD 带校验解析60Ajv JTD 带校验序列化55这个数字不是精确基准但趋势是明确的把校验和解析合并能省掉一次完整遍历收益在 30% 到 40% 左右。数据量越大、字段越多收益越明显。调优建议schema 尽量扁平嵌套层级不要太深。JTD 对嵌套的处理没问题但扁平结构生成的代码更简单。判别联合的mapping不要太大几十个分支以内性能都很好上百个分支时考虑拆分。复用编译结果不要在热路径里 compile。如果只需要校验不需要解析用compile而不是compileParser后者会多做解析工作。6. 常见问题与排查技巧实录6.1 校验失败但看不出原因最常见的情况是additionalProperties默认 false 导致的。接口返回多了一个字段校验就失败了但错误信息可能只告诉你“有额外属性”。排查方法是先把allErrors打开然后看instancePath指向哪里。如果指向根对象多半是顶层多了字段如果指向某个嵌套对象就是那个对象里多了字段。另一个常见原因是数值类型不匹配。比如 schema 写的是uint8但实际值是 300超出范围就失败。JTD 的数值范围检查是严格的uint8就是 0 到 255没有商量余地。排查时把实际值和 schema 声明的范围对一下。6.2 类型推导结果和预期不一致类型工具推导出来的类型有时候会比你预期的宽。比如{}推导成unknownnullable: true推导成T | null。这些是符合规范的不是 bug。如果你需要更窄的类型要么改 schema要么在业务代码里做类型收窄。还有一个坑as const必须加否则 schema 会被推断成宽泛的string类型类型工具就提取不出字面量了。这个错误很隐蔽因为不加as const代码也能跑只是类型推导失效。6.3 解析函数返回 undefined 但不知道哪里错了compileParser返回的解析函数在失败时返回undefined错误信息在parse.message和parse.position上。position是字符偏移量可以配合原始字符串定位到出错位置。实际排查时我习惯把message和position一起打出来然后截取原始字符串附近的内容看。如果position是 0 或者接近末尾可能是整体结构问题比如 JSON 本身格式错误。如果position在中间多半是某个字段的值不符合 schema。6.4 常见问题速查表现象可能原因解决方向校验失败提示额外属性additionalProperties默认 false显式声明或加additionalProperties: true数值校验失败超出声明类型的范围检查uint8/int16等范围类型推导为 unknownschema 用了{}改用具体类型或做类型收窄类型推导失效没加as const给 schema 加as const解析返回 undefined数据不符合 schema 或 JSON 格式错误看message和position序列化失败对象上有未声明字段检查 schema 或加透传字段编译很慢schema 太大或太复杂拆分 schema减少嵌套6.5 几个我踩过的坑第一个坑在循环里 compile。早期写代码时没注意每次处理数据都ajv.compile(schema)性能惨不忍睹。后来改成模块顶层编译一次性能立刻正常。这个错误很基础但确实容易犯尤其是从其他校验库迁移过来的时候。第二个坑判别联合的discriminator字段被当成普通字段又声明了一遍。JTD 里discriminator指定的字段是自动处理的不需要也不应该在properties里再写一次。写了会怎样行为不确定不同版本可能表现不同。正确做法就是只在discriminator里声明。第三个坑以为 JTD 支持enum。JTD 没有enum关键字字符串枚举需要用判别联合或者手动校验。如果你的场景确实需要枚举要么用判别联合模拟要么在 JTD 校验之后再加一层业务校验。这是 JTD 为了保持规范简洁做的取舍用之前要心里有数。7. 实际项目里的组合用法7.1 接口边界校验最常见的用法是在 API 入口处用 JTD 校验请求体。Express 或 Fastify 里可以写一个中间件把 schema 编译一次然后每个请求复用。校验失败直接返回 400不进入业务逻辑。这样业务代码里拿到的数据一定是符合 schema 的类型推导也能直接用上。Fastify 本身对 JSON Schema 有内置支持但如果你用 JTD可以自己封装一个 preHandler。好处是 JTD 的严格性让接口契约更清晰前端多传字段会直接报错而不是被静默忽略。7.2 配置文件读取配置文件是 JTD 的另一个好场景。配置文件通常结构固定、字段明确用 JTD 校验能避免“配置写错了但程序照跑”的问题。读文件、parse、校验三步走完配置不对就启动失败比运行时才发现问题好得多。7.3 LLM 工具调用参数校验现在很多项目会接 LLM 的工具调用模型返回的 tool payload 结构不一定可靠。用 JTD 的判别联合来校验工具参数非常合适每个工具一个分支discriminator用工具名或者类型标签。校验不过就拒绝执行避免把脏数据传给下游。这个场景下带校验的解析特别有用因为 LLM 返回的往往是 JSON 字符串直接 parse 加校验一步到位省事。7.4 数据管道中的序列化如果你的服务之间用 JSON 传递数据发送方用 JTD 序列化能保证发出去的数据合法接收方用 JTD 解析能保证收到的数据合法。两端用同一份 schema契约就锁死了。schema 变更时两端一起改不会出现一端发了新字段另一端不认识的情况。8. 选型时的几个判断点JTD 不是万能的它适合“结构明确、需要高性能、跨语言对齐”的场景。如果你的 schema 需要复杂的条件组合、动态字段名、或者大量复用远程 schemaJSON Schema 可能更合适。Ajv 同时支持两者你可以在一个项目里按场景选用。判断标准很简单如果你的 schema 用 JTD 能表达清楚就用 JTD性能和类型工具都更好如果 JTD 表达不了再考虑 JSON Schema。不要为了用 JTD 而扭曲数据结构也不要因为 JSON Schema 更灵活就无脑用它。我个人在实际项目里的体会是大部分接口和配置场景JTD 的表达能力都够用而且严格性带来的好处远大于灵活性带来的便利。真正需要 JSON Schema 的场景往往是那些 schema 本身就要动态生成或者需要复杂组合的少数情况。最后分享一个小技巧如果你在迁移现有 JSON Schema 到 JTD不要一次性全改。先挑一个结构最简单的 schema 试水跑通校验、类型推导、解析序列化整条链路确认没问题再逐步迁移。迁移过程中两套 schema 可以共存Ajv 不会因为它们同时存在就出问题。