
scalar/validation 深入解析Schema 校验、数据规范化与递归循环检测【免费下载链接】scalarScalar is an open-source API platform: Modern REST API Client Beautiful API References ✨ 1st-Class OpenAPI/Swagger Support项目地址: https://gitcode.com/GitHub_Trending/sc/scalar导读scalar/validation是 Scalar 开源 API 平台中一个轻量、schema 优先的运行时校验库提供validate判断数据是否符合 schema与coerce把未知数据规范化成可预测的结构两个核心能力并通过Static类型把 schema 直接推导为 TypeScript 类型。本文以该包的 CHANGELOG.md 记录的功能演进入手结合 源码 与 测试用例 逐层拆解其设计从基础校验器到可处理自引用、相互递归结构的循环检测机制再到 union 分支评分启发式与 intersection 一等支持。读完你可以完整掌握该库的 API、内部实现原理以及它在 Scalar 生态中承担的角色。一、包定位与整体结构scalar/validation的定位是「小而精的 schema 校验工具」schema 就是普通 JavaScript 对象易于序列化、日志输出和跨进程传递TypeScript 侧通过StaticS从 schema 推导出匹配数据的静态类型。该包位于 monorepo 的 packages/validation 目录包元信息见 package.json当前版本为0.6.3要求 Node.js 20使用 pnpm 管理。包的入口 src/index.ts 统一导出核心函数validate、coerce、generateTypes全部 schema 构建器number、string、boolean、nullable、notDefined、any、unknown、fn、array、record、object、union、intersection、optional、literal、lazy、evaluate类型Static以及AnySchema、UnionSchema、IntersectionMember、UnionMember等一整套 schema 类型源码文件结构清晰职责单一文件职责src/schema.ts全部 schema 类型定义与构建器实现src/validate.tsvalidate运行时实现含循环检测src/coerce.tscoerce运行时实现含评分与循环检测src/types.tsStatic类型推断引擎src/typegen.ts从 schema 生成 TypeScript 声明文本src/helpers/is-object.ts「纯对象」判定工具二、两个核心 APIvalidate与coercevalidate(schema, value)— 是与否validate返回布尔值value满足schema则为true否则为false。其实现见 src/validate.ts内部递归函数validateInner针对每种 schema 类型分派判断undefinedschema— 永远返回falsenumber()— 仅接受有限数字NaN、Infinity均判定失败对应typeof value number !Number.isNaN(value) Number.isFinite(value)见 validate.tsobject({ ... })— 值必须是「纯对象」逐个校验声明的属性不拒绝多余属性union([...])— 任一分支匹配即通过intersection([...])— 所有成员都匹配才通过空 intersection 视为无约束空真直接返回true见 validate.ts。快速上手来自 README.mdimport { coerce, number, object, string, validate, type Static } from scalar/validation const userSchema object({ id: number(), name: string(), }) type User Statictypeof userSchema validate(userSchema, { id: 1, name: Ada }) // true validate(userSchema, { id: 1, name: 2 }) // falsecoerce(schema, value)— 规范化而非严格校验coerce返回Statictypeof schema类型的结果。它不是严格校验而是「向 schema 靠拢」的规范化过程实现在 src/coerce.ts合法的基础值原样返回非法number→0、非法string→、非法boolean→false若 schema 配置了default则回退到该默认值nullable()→ 结果恒为nullnotDefined()→ 结果恒为undefinedliteral(x)→ 结果恒为 schema 声明的字面量xarray/object/record— 递归构建形状不对时退化为空容器或默认字段union— 通过「评分」启发式选择最匹配的分支对象形状与字面量标签的权重高于「属性是否存在」object规范化时会丢弃未声明的多余属性并在 optional 属性值为undefined时省略该键见 coerce.ts。// Best-effort shaping: invalid primitives fall back to defaults coerce(userSchema, { id: x, name: Ada }) // { id: 0, name: Ada }选型建议需要「是/否」结论用validate需要拿到一份稳定的、带默认值填充的结构例如规范化配置对象或解析后的 JSON用coerce。三、Schema 构建器全景schema 构建器定义在 src/schema.ts每个构建器都返回一个带type判别字段的普通对象。完整清单如下来自 README.md构建器校验规则Static类型示意number()有限numbernumberstring()stringstringboolean()booleanbooleannullable()仅nullnullnotDefined()仅undefinedundefinedany()任意值anyunknown()任意值unknownfn()仅函数运行时不检查签名函数签名literal(v)与v严格相等typeof varray(item)数组且每项匹配itemStaticitem[]record(key, value)纯对象且键值均匹配Record…, …object(props)纯对象且声明属性逐个匹配字段静态类型组成的对象union([a, b, …])任一成员匹配各分支联合类型intersection([...])所有成员匹配成员须为对象 schema各分支交叉类型optional(s)undefined或匹配sStatics \| undefined在object中表现为key?: Staticslazy(() schema)延迟求值 schema用于递归由内部 schema 推断evaluate(fn, schema)先执行fn(value)再校验schemaStaticschema其中number/string/boolean三个构建器还接受可选的{ default?: … }配置作为coerce失败时的回退值见 schema.ts。所有构建器lazy、evaluate、literal除外还接受typeName/typeComment元数据供类型生成与文档使用。evaluate— 先转换再校验evaluate(expression, schema)会在校验前对输入执行一次转换适合「解析需要预处理」的场景import { evaluate, number, string } from scalar/validation const trimmed evaluate((v) (typeof v string ? v.trim() : v), string()) validate(trimmed, hi ) // true (after trim)实现上validate对evaluate分支调用validateInner(schema.schema, schema.expression(value), cache)validate.tscoerce则用coerceInner(schema.schema, schema.expression(value), …)转换后继续递归coerce.ts。对象与记录理解「纯对象」语义isObjectsrc/helpers/is-object.ts判定「纯对象」的标准是非null、非数组且原型为Object.prototype或null。这意味着Date、RegExp、Error、Map、Set、Promise以及大多数类实例都不算对象无法通过object()/record()校验——测试用例中也专门验证了validate(record(string(), string()), new Date())与new Uint8Array()均为false见 validate.test.ts。object只检查你列出的键缺失键按undefined读取字段可能缺失时应配合optional(...)使用record在校验时对每个键和值都做递归检查在coerce时键保持字符串原样仅对值做规范化。四、0.6.0 核心能力递归 Schema 与循环检测CHANGELOG 中 0.6.0 是最重要的一个里程碑PR #9262validate和coerce全面支持递归 schema。递归 schema 通常由lazy(() self)定义例如树、链表、嵌套导航结构而自引用值如node.child node若不做特殊处理会让朴素递归实现无限递归、最终栈溢出。validate调用栈级别的(value, schema)循环标记validateInner通过一个WeakMapobject, SetSchema缓存「当前调用栈上正在校验的 (value, schema) 组合」来实现循环终止见 validate.ts进入时若(value, schema)已在缓存中说明该组合正在调用栈更上层被校验——这是循环直接短路返回true任何具体的不匹配会在外层调用中暴露否则将组合标记为 in-progress进入各类型分派finally块中在返回前清除标记保证缓存只描述「活跃调用栈」而非「本次运行访问过的全部组合」。测试用例覆盖了完整的循环家族validate.test.ts// 自引用值 递归 lazy schema终止且通过 type Node { name: string; child?: Node } const T: ReturnTypetypeof lazy lazy(() object({ name: string(), child: optional(T) })) const node: Node { name: root } node.child node expect(() validate(T, node)).not.toThrow() expect(validate(T, node)).toBe(true) // 相互递归的 lazy schema 循环数据 const SchemaA lazy(() object({ kind: literal(a), next: optional(SchemaB) })) const SchemaB lazy(() object({ kind: literal(b), next: optional(SchemaA) })) a.next b; b.next a expect(validate(SchemaA, a)).toBe(true) // 自引用数组 const T: ReturnTypetypeof lazy lazy(() array(lazy(() T))) arr.push(arr) expect(validate(T, arr)).toBe(true)同时循环值中若某属性校验失败仍然会正确返回false——循环短路不会掩盖真实错误。coerce预分配 结果缓存 lazy 解析缓存coerce处理循环的方式更精细涉及三层机制coerce.tstrackCycle预分配结果对array/record/object先分配结果容器并调用trackCycle注册「(value, schema) → result」再递归填充元素。自引用数组/对象在递归入口就能取到已分配的容器从而打破循环见 coerce.tslazyCacheWeakMap记忆化lazy内层 schemaresolveLazy把每个lazy工厂解析出的内层 schema 缓存下来保证递归定义如lazy(() object({ child: lazy(() T) }))每次遍历解析到同一个 schema 引用。否则每次遍历都会生成新的内层 schema导致(value, schema)循环缓存失效、自引用值上无限递归见 coerce.tsscoreUnion评分递归的循环标记union 分支评分同样可能遇到lazy → union → object → property → lazy → …的无限递归详见下一节。scoreUnion评分时的循环短路coerce选择 union 分支靠的是scoreUnion评分函数。CHANGELOG 专门记录了它的循环修复scoreUnion现在跟踪「调用栈上正在评分的 (value, schema) 组合」重入时返回中性正分1而不是0——这与validateInner将循环短路为true保持一致见 coerce.ts。标记同样在finally中清除让共享同一 schema 引用的兄弟分支独立评分。五、Union 分支评分的启发式细节coerce对 union 的默认行为是对所有分支执行scoreUnion取分数最高的分支进行规范化平局时取第一个见 coerce.ts。评分的权重设计体现了「形状比存在性更重要」字面量判别属性discriminatorisDiscriminatorProperty识别出「仅用于区分分支」的属性——单个literal或全部由literal组成的union。这类属性匹配时权重×10acc base * 10不匹配时记0不享受「键存在」加分从而让type: literal(A)胜过另一个分支上不相关的字段见 coerce.ts普通属性递归评分值为0时仍加1键存在就计分使{ a: null }也能倾向声明了a的分支无属性的对象 schema 计1分空对象分支要能压过内联原始类型分支数组/record按结构类型粗评分1/0union 嵌套取所有子分支的最高分intersection各成员分数求和。coerce.test.ts用大量「启发式推断」用例锁定了这些行为例如{ type: A, a: a }会因type: A的判别权重被规范化成 A 分支的{ type: A, x: 0, y: 0, z: 0 }见 coerce.test.ts嵌套 union 与共享属性如summary、$ref结构的评分也有专门用例覆盖coerce.test.ts。六、缓存作用域不跨 Union 分支泄漏CHANGELOG 中 0.6.0 还记录了两个紧密相关的 bug 修复都属于循环检测缓存的作用域问题validate的缓存不得作为「运行级 memoization」union([intersection([base, objA]), intersection([base, objB])])中两个分支共享同一个baseschema 引用。如果分支 1 校验base失败后留下陈旧标记分支 2 会把该标记误认为「循环短路成功」而错误放行非法值。修复方式就是第四节讲的「返回前清除标记」。对应的回归测试见 validate.test.ts{ kind: a, a: 1 }通过、{ b: 1 }缺少kind在两个分支都必须失败、返回falsescoreUnion的评分标记同样按调用栈作用域递归lazyschema 对自引用值评分时重入返回中性正分标记在finally清除后兄弟分支可独立评分。这两个修复共同保证了循环检测只用于终止「确实在递归下降」的环而不会污染共享 schema 在不同分支中的独立判定。七、默认值支持与一等公民的 optional / intersection默认值0.5.0 / 0.4.0number({ default: 42 })、string({ default: fallback })、boolean({ default: true })在coerce遇到非法输入时回退到该默认值合法值时忽略默认值原样返回未配置时分别回退到0//false。测试见 coerce.test.ts。optional 与 intersection 的一等支持0.2.0optional(s)在运行时接受undefined或匹配值在Static与类型生成中把对象属性映射为key?: …见 types.tsintersection([...])要求值是纯对象且每个成员对象 schema 都满足0.3.0 起还支持「union of object 作为 intersection 分支的直接子元素」。intersection 的完整行为矩阵见 validate.test.ts重叠键需满足所有声明它的分支、非纯对象直接拒绝、空 intersection 空真、嵌套 intersection 递归校验、支持 lazy 成员。// 判别式联合的经典写法 const message union([ object({ type: literal(text), body: string() }), object({ type: literal(ping) }), ]) // intersection合并多个对象形状 const T intersection([object({ a: number() }), object({ b: string() })]) validate(T, { a: 1, b: ok }) // true八、类型层Static推断与循环安全StaticS定义在 src/types.ts通过带深度计数器的内部类型_StaticT, Depth递归展开默认深度上限为10StaticT _StaticT, 10避免极深类型上的无限递归types.ts。CHANGELOG 指出 0.6.0 对循环 schema 的类型推断做了专门优化LazyStatic通过命名的泛型别名间接引用LazySchema——TypeScript 会缓存并惰性展开命名别名使得lazy(() self)这种循环引用在类型层可以被重入而不会立刻命中深度上限types.tsUnionObjectStatics/IntersectObjectStatics分别把 union / intersection 成员的静态类型折叠为联合 / 交叉类型types.tsUnionMember/IntersectionMember轻量约束union/intersection的入参类型故意做成「仅判别字段」的结构类型避免在调用点触发 TypeScript 对元组元素的急切求值从而支持lazy(() self)的循环类型推断schema.tsSafeStaticcoerce的返回类型在S收窄为完整Schema联合时退化为any否则计算StaticSchema会触发TS2589: Type instantiation is excessively deep and possibly infinite具体 schema 仍返回精确类型coerce.ts。typegen测试typegen.test.ts与coerce.types.test.ts对这些类型行为做了编译期验证。九、从 Schema 生成 TypeScript 声明generateTypes除了运行时校验该包还提供generateTypes(schema, options)src/typegen.ts把 schema 直接渲染成 TypeScript 声明文本。选项包括maxDepth递归深度上限默认10超出输出anygeneratedAt生成文件的 ISO 8601 时间戳默认取调用时刻namespace合法的 TS 标识符时把所有export type包裹进export namespace Name { ... }typeName根 schema 以export type typeName …输出覆盖 schema 自带typeName。schema 上带typeName的节点会被抽成具名export type别名并被其他位置按名引用整个输出以「本文件自动生成请勿手动编辑」的 banner 开头typeComment则被渲染为 JSDoc 注释。递归 schemalazy在生成时通过inProgress集合避免重复展开typegen.ts。十、测试与开发该包使用 Vitest 编写测试主要覆盖文件为 validate.test.ts1019 行覆盖全部构建器、对象/record 语义、union/intersection 行为与完整循环家族与 coerce.test.ts1565 行覆盖规范化默认值、启发式分支选择、嵌套 union 评分另有 typegen.test.ts 与 coerce.types.test.ts 做类型层验证。开发与验证命令来自 package.json# 运行单元测试vitest pnpm --filter scalar/validation test # 类型检查 pnpm --filter scalar/validation types:check # 构建tsc tsc-alias pnpm --filter scalar/validation build结语从 0.1.0 的初始提交到 0.2.0 补齐 optional / intersection再到 0.6.0 系统性引入递归 schema 支持与循环检测scalar/validation的演进主线非常清晰在保持 schema 可序列化的前提下把「校验」与「规范化」都做到能安全处理任意深度、甚至自引用的数据结构。validate提供确定性的真假判定coerce提供带默认值填充的稳定输出Static与generateTypes则把同一份 schema 同时用于运行时与编译期。理解其调用栈级循环检测、union 评分启发式与缓存作用域设计也能为你在其他语言或框架中实现同类 schema 引擎提供直接借鉴。【免费下载链接】scalarScalar is an open-source API platform: Modern REST API Client Beautiful API References ✨ 1st-Class OpenAPI/Swagger Support项目地址: https://gitcode.com/GitHub_Trending/sc/scalar创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考