@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 侧通过Static<S>从 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.ts | validate运行时实现(含循环检测) |
| src/coerce.ts | coerce运行时实现(含评分与循环检测) |
| src/types.ts | Static类型推断引擎 |
| src/typegen.ts | 从 schema 生成 TypeScript 声明文本 |
| src/helpers/is-object.ts | 「纯对象」判定工具 |
二、两个核心 API:validate与coerce
validate(schema, value)— 是与否
validate返回布尔值:value满足schema则为true,否则为false。其实现见 src/validate.ts,内部递归函数validateInner针对每种 schema 类型分派判断:
undefinedschema— 永远返回false;number()— 仅接受有限数字,NaN、Infinity均判定失败(对应typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value),见 validate.ts);object({ ... })— 值必须是「纯对象」,逐个校验声明的属性,不拒绝多余属性;union([...])— 任一分支匹配即通过;intersection([...])— 所有成员都匹配才通过;空 intersection 视为无约束(空真),直接返回true(见 validate.ts)。
快速上手(来自 README.md):
import { coerce, number, object, string, validate, type Static } from '@scalar/validation' const userSchema = object({ id: number(), name: string(), }) type User = Static<typeof userSchema> validate(userSchema, { id: 1, name: 'Ada' }) // true validate(userSchema, { id: 1, name: 2 }) // falsecoerce(schema, value)— 规范化而非严格校验
coerce返回Static<typeof schema>类型的结果。它不是严格校验,而是「向 schema 靠拢」的规范化过程,实现在 src/coerce.ts:
- 合法的基础值原样返回;
- 非法number→
0、非法string→''、非法boolean→false(若 schema 配置了default,则回退到该默认值); nullable()→ 结果恒为null;notDefined()→ 结果恒为undefined;literal(x)→ 结果恒为 schema 声明的字面量x;array/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() | 有限number | number |
string() | string | string |
boolean() | boolean | boolean |
nullable() | 仅null | null |
notDefined() | 仅undefined | undefined |
any() | 任意值 | any |
unknown() | 任意值 | unknown |
fn() | 仅函数(运行时不检查签名) | 函数签名 |
literal(v) | 与v严格相等 | typeof v |
array(item) | 数组且每项匹配item | Static<item>[] |
record(key, value) | 纯对象且键值均匹配 | Record<…, …> |
object(props) | 纯对象且声明属性逐个匹配 | 字段静态类型组成的对象 |
union([a, b, …]) | 任一成员匹配 | 各分支联合类型 |
intersection([...]) | 所有成员匹配(成员须为对象 schema) | 各分支交叉类型 |
optional(s) | undefined或匹配s | Static<s> \| undefined;在object中表现为key?: Static<s> |
lazy(() => schema) | 延迟求值 schema(用于递归) | 由内部 schema 推断 |
evaluate(fn, schema) | 先执行fn(value)再校验schema | Static<schema> |
其中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.ts),coerce则用coerceInner(schema.schema, schema.expression(value), …)转换后继续递归(coerce.ts)。
对象与记录:理解「纯对象」语义
isObject(src/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 #9262):validate和coerce全面支持递归 schema。递归 schema 通常由lazy(() => self)定义,例如树、链表、嵌套导航结构;而自引用值(如node.child = node)若不做特殊处理,会让朴素递归实现无限递归、最终栈溢出。
validate:调用栈级别的(value, schema)循环标记
validateInner通过一个WeakMap<object, Set<Schema>>缓存「当前调用栈上正在校验的 (value, schema) 组合」来实现循环终止(见 validate.ts):
- 进入时,若
(value, schema)已在缓存中,说明该组合正在调用栈更上层被校验——这是循环,直接短路返回true(任何具体的不匹配会在外层调用中暴露); - 否则将组合标记为 in-progress,进入各类型分派;
finally块中在返回前清除标记,保证缓存只描述「活跃调用栈」而非「本次运行访问过的全部组合」。
测试用例覆盖了完整的循环家族(validate.test.ts):
// 自引用值 + 递归 lazy schema:终止且通过 type Node = { name: string; child?: Node } const T: ReturnType<typeof 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: ReturnType<typeof lazy> = lazy(() => array(lazy(() => T))) arr.push(arr) expect(validate(T, arr)).toBe(true)同时,循环值中若某属性校验失败,仍然会正确返回false——循环短路不会掩盖真实错误。
coerce:预分配 + 结果缓存 + lazy 解析缓存
coerce处理循环的方式更精细,涉及三层机制(coerce.ts):
trackCycle预分配结果:对array/record/object,先分配结果容器并调用trackCycle注册「(value, schema) → result」,再递归填充元素。自引用数组/对象在递归入口就能取到已分配的容器,从而打破循环(见 coerce.ts);lazyCache(WeakMap)记忆化lazy内层 schema:resolveLazy把每个lazy工厂解析出的内层 schema 缓存下来,保证递归定义(如lazy(() => object({ child: lazy(() => T) })))每次遍历解析到同一个 schema 引用。否则每次遍历都会生成新的内层 schema,导致(value, schema)循环缓存失效、自引用值上无限递归(见 coerce.ts);scoreUnion评分递归的循环标记: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)。评分的权重设计体现了「形状比存在性更重要」:
- 字面量判别属性(discriminator):
isDiscriminatorProperty识别出「仅用于区分分支」的属性——单个literal,或全部由literal组成的union。这类属性匹配时权重×10(acc + base * 10),不匹配时记0(不享受「键存在」加分),从而让type: literal('A')胜过另一个分支上不相关的字段(见 coerce.ts); - 普通属性:递归评分,值为
0时仍加1(键存在就计分),使{ a: null }也能倾向声明了a的分支; - 无属性的对象 schema 计
1分:空对象分支要能压过内联原始类型分支; - 数组/record:按结构类型粗评分(
1/0); - union 嵌套:取所有子分支的最高分;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)在两个分支都必须失败、返回false;scoreUnion的评分标记同样按调用栈作用域:递归lazyschema 对自引用值评分时,重入返回中性正分;标记在finally清除后,兄弟分支可独立评分。
这两个修复共同保证了:循环检测只用于终止「确实在递归下降」的环,而不会污染共享 schema 在不同分支中的独立判定。
七、默认值支持与一等公民的 optional / intersection
- 默认值(0.5.0 / 0.4.0):
number({ default: 42 })、string({ default: 'fallback' })、boolean({ default: true })在coerce遇到非法输入时回退到该默认值;合法值时忽略默认值原样返回;未配置时分别回退到0/''/false。测试见 coerce.test.ts。 - optional 与 intersection 的一等支持(0.2.0):
optional(s)在运行时接受undefined或匹配值,在Static与类型生成中把对象属性映射为key?: …(见 types.ts);intersection([...])要求值是纯对象且每个成员对象 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推断与循环安全
Static<S>定义在 src/types.ts,通过带深度计数器的内部类型_Static<T, Depth>递归展开,默认深度上限为10(Static<T> = _Static<T, 10>),避免极深类型上的无限递归(types.ts)。
CHANGELOG 指出 0.6.0 对循环 schema 的类型推断做了专门优化:
LazyStatic:通过命名的泛型别名间接引用LazySchema——TypeScript 会缓存并惰性展开命名别名,使得lazy(() => self)这种循环引用在类型层可以被重入,而不会立刻命中深度上限(types.ts);UnionObjectStatics/IntersectObjectStatics:分别把 union / intersection 成员的静态类型折叠为联合 / 交叉类型(types.ts);UnionMember/IntersectionMember轻量约束:union/intersection的入参类型故意做成「仅判别字段」的结构类型,避免在调用点触发 TypeScript 对元组元素的急切求值,从而支持lazy(() => self)的循环类型推断(schema.ts);SafeStatic:coerce的返回类型在S收窄为完整Schema联合时退化为any(否则计算Static<Schema>会触发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,超出输出any;generatedAt:生成文件的 ISO 8601 时间戳(默认取调用时刻);namespace:合法的 TS 标识符时,把所有export type包裹进export namespace Name { ... };typeName:根 schema 以export type <typeName> = …输出(覆盖 schema 自带typeName)。
schema 上带typeName的节点会被抽成具名export type别名并被其他位置按名引用,整个输出以「本文件自动生成,请勿手动编辑」的 banner 开头;typeComment则被渲染为 JSDoc 注释。递归 schema(lazy)在生成时通过inProgress集合避免重复展开(typegen.ts)。
十、测试与开发
该包使用 Vitest 编写测试,主要覆盖文件为 validate.test.ts(1019 行,覆盖全部构建器、对象/record 语义、union/intersection 行为与完整循环家族)与 coerce.test.ts(1565 行,覆盖规范化默认值、启发式分支选择、嵌套 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),仅供参考