- 后端
【免费下载链接】arktype
TypeScript's 1:1 validator, optimized from editor to runtime
导读
@ark/attest是 ArkType 生态中的测试基础设施:它让 TypeScript 类型在运行时"可见",提供类型级断言(attest<expected>(value))、类型字符串快照(type.toString.snap())、自动补全快照、JSDoc 断言、错误断言,以及确定性的"类型实例化数"(instantiations)基准测试。本篇文章以 ark/attest/CHANGELOG.md 为脉络,结合 ark/attest/README.md 与仓库源码,梳理 attest 从早期版本到 0.51.0 的关键能力演进——读完你将掌握它的配置体系(CLI 参数 / 环境变量 / setup 函数三通道)、断言与快照的底层实现,以及如何在自己的项目或库中集成这套类型测试能力。
说明:该 CHANGELOG 自身注明"并不完整",很多更新只是随
arktype版本一起 bump 的断言调整;但它完整记录了 attest 独有的重要变更,配合源码可以还原出相当完整的图谱。
核心断言能力:从equals到satisfies、toString与正则匹配
equals 的健壮性改造(0.41.0):避免深比较导致的 OOM
在 0.41.0 之前,attest(...).equals(...)会直接依赖 Node 的deepStrictEqual做深比较。对带有递归引用(例如对象属性引用了Type实例)的值做深比较时,会直接导致OOM(内存耗尽)崩溃,这正是 issue #1287 描述的问题。
0.41.0 的短期方案是:先做构造函数(constructor)级别的浅比较,避免常见的病态深比较。这在 assertions.ts 的unversionedAssertEquals中可以看到具体逻辑:
- 当
expected与actual都是对象且构造函数相同时,才走deepStrictEqual; - 当构造函数不同时,直接抛出
AssertionError,并提示Objects did not have the same constructor,同时用printable序列化两侧内容辅助排错; - 当两侧至少有一方是函数或对象(非引用相等)时,也直接报错而不是递归比较。
于是此前会 OOM 的用例现在会得到浅显的失败结果:
// 之前可能触发 OOM 异常,现在会浅显失败并给出简单错误 attest(type.string).equals(type.boolean)这一改造在测试 assertions.test.ts 中有对应验证:attest(() => attest(type.string).equals(type.number)).throws.equals(...)断言其抛出的是"not between reference equal items"风格的错误,而ArkError与普通对象比较时则会给出Objects did not have the same constructor的信息。CHANGELOG 同时注明,未来会引入真正的字符串 diff 逻辑来给出更友好的差异展示。
satisfies:用 ArkType 定义直接断言任意值(0.8.1 / 0.11.0)
0.8.1 引入了.satisfies断言,它把一个 ArkType 定义当作约束来校验当前断言的值:
attest({ foo: "bar" }).satisfies({ foo: "string" }) // Error: foo must be a number (was string) attest({ foo: "bar" }).satisfies({ foo: "number" })在源码中,satisfies通过type.raw(def)将传入的定义编译为 ArkType 类型,再调用其assert方法(见 chainableAssertions.ts)。0.11.0 又允许对type.toString这类类型断言结果使用satisfies进行正则/局部匹配:
// ok attest({ ark: "type" }).type.toString.satisfies(/^{.*}$/) // AssertionError: ark must be a number (was string) attest({ ark: "type" }).satisfies({ ark: "number" })从类型层看,satisfies还做了约束重叠校验:如果期望类型与约束类型无交集,会直接触发This type has no overlap with your satisfies constraint的类型错误(nonOverlappingSatisfiesMessage,见 chainableAssertions.ts),把错误前置到编译期。
toString断言支持正则/局部匹配(0.11.0)
0.11.0 起,type.toString不再只能精确等于一个字符串,还支持传入正则进行匹配,实现函数为assertEqualOrMatching(见 assertions.ts):
- 期望值是字符串时:检查实际字符串是否包含该子串(
actual.includes(expected)); - 期望值是
RegExp时:检查是否匹配,失败时报Actual string '...' did not match regex '...'; - 实际值不是字符串时直接报错。
// ok attest({ ark: "type" }).type.toString(/^{.*}$/) // AssertionError: Actual string 'string[]' did not match regex '^{.*}$' attest(["ark", "type"]).type.toString(/^{.*}$/)这也解释了为什么ChainableAssertions中大量断言携带allowRegex标志:type.toString、type.errors、jsdoc、throws都允许用字符串子串或正则做宽松匹配。
用 prettier 格式化类型序列化:0.10.0 与 0.11.0 的typeToStringFormat
0.10.0 是一个重要的可读性里程碑:序列化类型字符串改用 prettier 格式化。在引入格式化之前,长的对象类型被序列化成一行:
// old(单行,难以阅读) attest({ ark: "type", type: "script", vali: "dator", opti: "mized", from: "editor", to: "runtime" }).type.toString.snap( `{ ark: string; type: string; vali: string; opti: string; from: string; to: string; }` ) // new(多行、缩进,可读性大幅提升) attest({ ark: "type", type: "script", vali: "dator", opti: "mized", from: "editor", to: "runtime" }).type.toString.snap(`{ ark: string type: string vali: string opti: string from: string to: string }`)该格式化逻辑在 chainableAssertions.ts 的formatTypeString中实现:它把类型字符串包成type T = <typeString>交给prettier.format,再截掉声明前缀,默认配置为{ semi: false, printWidth: 60, trailingComma: "none", parser: "typescript" },其中printWidth: 60是专门针对类型序列化优化过的宽度。
破坏性影响与迁移路径:由于格式化规则变化,0.10.0 之后已有的类型快照大概率会因格式不一致而失败。CHANGELOG 给出了两种重建快照的方式:
# 方式一:测试时带 --updateSnapshots 标志 # 方式二:设置环境变量 ATTEST_updateSnapshots=1 vitest run对于非快照的type.toString断言(例如硬编码的字符串期望),则需要手动更新——CHANGELOG 建议临时把它们改成快照,方便直接看到正确值再固化。
0.11.0 进一步提供了typeToStringFormat配置,用于覆盖 prettier 的序列化选项。除默认值外,任何你提供的选项都会覆盖对应默认项;最便捷的提供方式是传入setup:
import * as attest from "@ark/attest" export const setup = () => attest.setup({ // 例如收紧类型序列化宽度 typeToStringFormat: { printWidth: 40 } })它也可以作为 JSON 序列化字符串,通过--typeToStringFormatCLI 参数或ATTEST_typeToStringFormat环境变量传入。底层支持在 config.ts 的getParamValue中可以看到:typeToStringFormat与compilerOptions一样会被JSON.parse解析后并入配置。
JSDoc 断言:0.44.0
0.44.0 为被断言的值增加了 JSDoc 关联内容断言能力——你可以匹配或快照与某个值关联的 JSDoc 注释:
const T = type({ /** FOO */ foo: "string" }) const out = T.assert({ foo: "foo" }) // match or snapshot expected jsdoc associated with the value passed to attest attest(out.foo).jsdoc.snap("FOO")在源码 chainableAssertions.ts 中,jsdoc把实际值替换为一个TypeAssertionMapping,其actual取缓存数据中的data.jsdoc ?? ""并经过formatTypeString格式化;同时开启allowRegex,因此你既可以.snap("FOO")精确快照,也可以.jsdoc.equals("FOO")或传入正则做宽松匹配。测试 assertions.test.ts 验证了attest(o.foo).jsdoc.equals("FOO")通过、而attest(o.bar).jsdoc.equals("BAR")抛出AssertionError。
实例化数基准:attest.instantiations与默认抛错(0.8.0)
阈值超限默认抛错
0.8.0 之前,当attest.instantiations()超过benchPercentThreshold指定的百分比时,只是返回非零退出码。0.8.0 起改为默认直接在测试内抛出异常:
it("can snap instantiations", () => { type Z = makeComplexType<"asbsdfsaodisfhsda"> // 实际实例化数比快照值高出 20% 以上时,会在此处直接抛错 attest.instantiations([1, "instantiations"]) })实现层面,attest.instantiations通过instantiationDataHandler完成:当从 bench 上下文调用时,它用 TSServer 解析调用位置并计算该bench调用贡献的实例化数(getContributedInstantiations),然后与期望值比较并和基线对照(见 type.ts)。默认阈值为 20%,且benchErrorOnThresholdExceeded默认为true(见 config.ts)。
快照自动补全按字母序稳定化(0.8.0)
0.8.0 同时规定:快照的自动补全列表将按字母序排列。这对像 ArkType 关键字补全这样的大列表尤其重要(CHANGELOG 自嘲"更新到不想再更新")。例如对type([""])的补全快照形如:
attest(() => type([""])).completions({ "": [ "...", "===", "Array", "Date", "Error", "Function", "Map", "Promise", "Record", "RegExp", "Set", "WeakMap", "WeakSet", "alpha", "alphanumeric", "any", "bigint", "boolean", "creditCard", "digits", "email", "false", "format", "instanceof", "integer", "ip", "keyof", "lowercase", "never", "null", "number", "object", "parse", "semver", "string", "symbol", "this", "true", "undefined", "unknown", "uppercase", "url", "uuid", "void" ] })completions的底层实现把实际值替换为TypeAssertionMapping,actual取data.completions;如果补全因歧义(例如两个相同的字符串字面量)无法确定,缓存会写入一条错误消息并直接抛出(见 chainableAssertions.ts)。在skipTypes模式下它退化为chainableNoOpProxy,不执行任何断言。
快照体系与failOnMissingSnapshots(0.47.0)
0.47.0 新增failOnMissingSnapshots配置项,默认值取决于环境变量CI:设置了CI时为true,否则为false。这一逻辑在 config.ts 中直接可见:failOnMissingSnapshots: "CI" in process.env。
它的作用体现在snap快照流程(见 chainableAssertions.ts):当快照尚未填充(无参数调用、或updateSnapshots开启)时,如果failOnMissingSnapshots为true,会抛出MissingSnapshotError(.snap() at <位置> must be populated.),避免 CI 上出现"悄悄生成快照"导致的假绿。对应测试在 assertions.test.ts:
assert.throws( () => attestInternal("", { cfg: { failOnMissingSnapshots: true } }).snap(), MissingSnapshotError )快照更新在进程退出时统一落盘(cleanup/teardown→writeSnapshotUpdatesOnExit,见 fixtures.ts),这也是 attest 需要 globalSetup/globalTeardown 的原因之一。
配置三通道与完整默认值
Attest 配置可通过三种方式指定(参见 README.md):
- 进程参数:例如
--skipTypes、--benchPercentThreshold 10(布尔型直接以--flag形式出现,--updateSnapshots还有-u/--update别名); - 环境变量:带
ATTEST_前缀,例如ATTEST_skipTypes=1、ATTEST_benchPercentThreshold=10,值会被JSON.parse解析; - setup 函数参数:例如
attest.setup({ skipTypes: true, benchPercentThreshold: 10 })。
三条通道的合并逻辑在 config.ts:先读环境变量,再用 CLI 参数覆盖,最后setup通过ATTEST_CONFIG环境变量把传入的选项并入(见 fixtures.ts)。当前默认配置(config.ts):
export const getDefaultAttestConfig = (): BaseAttestConfig => ({ tsconfig: existsSync(fromCwd("tsconfig.json")) ? fromCwd("tsconfig.json") : undefined, compilerOptions: {}, attestAliases: ["attest", "attestInternal"], failOnMissingSnapshots: "CI" in process.env, updateSnapshots: false, skipTypes: false, skipInlineInstantiations: false, tsVersions: "default", benchPercentThreshold: 20, benchErrorOnThresholdExceeded: true, filter: undefined, testDeclarationAliases: ["bench", "it", "test"], formatCmd: `npm exec --no -- prettier --write`, shouldFormat: true, typeToStringFormat: {} })其中几个关键项值得展开:
skipTypes:跳过类型检查与所有依赖类型信息的断言,适合 watch 模式或 VSCode Test Explorer 下的快速迭代。官方推荐的脚本组合是:{ "test": "ATTEST_skipTypes=1 vitest run", "testWithTypes": "vitest run" }类型改动需要复验或跑 CI 时用
testWithTypes,日常开发与 watch 用test。tsconfig/compilerOptions:默认探测仓库根目录的tsconfig.json,也可通过 JSON 参数覆盖编译器选项。attestAliases:attest 只从这些名字的函数调用中收集类型数据,是集成自定义断言库的关键。testDeclarationAliases:["bench", "it", "test"],用于定位包含基准调用的外层测试/bench 声明,计算其贡献的实例化数(见 type.ts)。filter:可按名称过滤要执行的 bench(支持字符串或分段数组)。
Benches:类型与运行时混合基准(0.9.x 修复与 baseline 机制)
Attest 的bench与测试分离运行,不需要特殊 setup,直接用tsx benches.ts或ts-node benches.ts即可。其.types([n, "instantiations"])会确定性地报告 bench 内容贡献的 TypeScript 类型实例化数:
// 组合模板字面量常产生昂贵类型——来给这个做个基准! type makeComplexType<s extends string> = s extends `${infer head}${infer tail}` ? head | tail | makeComplexType<tail> : s bench("bench type", () => { return {} as makeComplexType<"defenestration"> // 这是内联快照,运行文件时会被填充或比对 }).types([169, "instantiations"]) bench( "bench runtime and type", () => { return {} as makeComplexType<"antidisestablishmentarianism"> }, fakeCallOptions ) // 函数执行平均耗时 .mean([2, "ms"]) // 类型看起来对输入长度呈 O(n)——还不错! .types([337, "instantiations"])运行时基准在 bench.ts 的ResultCollector中实现:默认以5 秒或 100_000 组调用(每组 1000 次显式调用)先到者为准,通过call1K显式循环 1000 次以规避 V8 对循环的优化;mean/median统计分别见stats对象。
Baseline(基线表达式)机制:如果基准对象是 API 的首次调用,其初始实例化会造成噪音,需要先放一个"基线表达式":
import { bench } from "@ark/attest" import { type } from "arktype" // baseline expression type("boolean") bench("single-quoted", () => { const _ = type("'nineteen characters'") // 没有基线时会是 2697 }).types([610, "instantiations"]) bench("keyword", () => { const _ = type("string") // 没有基线时会是 2507 }).types([356, "instantiations"])[!WARNING] 基线表达式绝不能与某个 bench 中的表达式相同,否则 bench 会复用其缓存类型,导致实例化数被低估甚至为 0。
CHANGELOG 中 0.9.2 修复了"连续多次运行 bench 无法内联填充快照"的 bug,0.9.4 则改进了基准源码提取并补充了基线表达式说明——可见这套机制是逐步打磨出来的。若要在 CI 上对超出阈值失败,可以这样跑:
tsx ./p99/within-limit/p99-tall-simple.bench.ts --benchErrorOnThresholdExceeded --benchPercentThreshold 10CLI:stats与trace(0.44.x 解耦 pnpm)
Attest 内置attestCLI,子命令包括precache、trace、stats(分发逻辑见 cli.ts):
# 汇总各包的关键类型性能指标(check 时间、实例化数、类型数量) npm run attest stats packages/* # 生成可被 perfetto 等工具查看的类型性能热力图 trace.json npm run attest trace .stats:接受任意数量的包目录参数,支持 glob(如packages/*),不给参数时默认检查 CWD(见 cli/stats.ts)。trace:接受单个根目录参数,在.attest/trace下生成trace.json,可导入 ui.perfetto.dev 查看;同时用@typescript/analyze-trace汇总热点。- 0.44.3 的"Decouple attest trace/stats from pnpm"意味着这些命令不再强依赖 pnpm——从实现看,只有当 attest 源码以
.ts形式运行时才调用pnpm attest precache,否则走npm exec -c "attest precache ..."(见 fixtures.ts)。
多 TypeScript 版本测试与自定义断言集成
tsVersions:一次测试多个 TS 版本
tsVersions允许同时测试多个 TypeScript 版本别名,别名必须以"typescript"开头的 package.json(dev)dependency 形式声明:
{ "typescript": "latest", "typescript-next": "npm:typescript@next", "typescript-1": "npm:typescript@5.2", "typescript-2": "npm:typescript@5.1" }传入"*"会运行所有发现的typescript*版本:
setup({ tsVersions: "*" })底层 tsVersioning.ts 会把当前node_modules/typescript临时重命名为typescript-temp,再依次软链目标版本并运行,最后无论成败都会恢复原版本;版本发现逻辑(findAttestTypeScriptVersions)会扫描当前包及所有父包的node_modules,同时识别typescript-*目录与@ark/attest-ts-*目录。多版本断言通过versionableAssertion逐版本执行,任一版本失败都会累积错误并抛出(见 assertions.ts)。
库作者集成:attestAliases与底层 API
如果你在写库并想把自己的断言函数接入 attest 的类型数据收集,需要在第一个测试运行前调用setup并列出自定义断言名:
// attest 只会从 attestAliases 中列出的函数调用收集类型数据 setup({ attestAliases: ["yourCustomAssert"] })setup 过程中 attest 会搜索这些断言调用并把类型缓存到临时文件(writeAssertionData→analyzeProjectAssertions,见 fixtures.ts),从而避免为每个测试进程都启动新的 TSServer。
最灵活的底层 API 是getTypeAssertionsAtPosition与caller(均从 index.ts 导出):
import { getTypeAssertionsAtPosition, caller } from "@ark/attest" const yourCustomAssert = <expectedType>(actualValue: expectedType) => { const position = caller() const types = getTypeAssertionsAtPosition(position) // 断言 actualValue 的类型与 expectedType 的类型一致 const relationship = types[0].args[0].relationships.typeArgs[0] if (relationship === undefined) { throw new Error( `yourCustomAssert requires a type arg representing the expected type, e.g. 'yourCustomAssert<"foo">("foo")'` ) } if (relationship !== "equality") { throw new Error( `Expected ${types.typeArgs[0].type}, got ${types.args[0].type} with relationship ${relationship}` ) } }用户即可获得友好的类型关系诊断:
test("my code", () => { // Ok yourCustomAssert<"foo">(`${"f"}oo` as const) // Error: `Expected boolean, got true with relationship subtype` yourCustomAssert<boolean>(true) // Error: `Expected 5, got number with relationship supertype` yourCustomAssert<5>(2 + 3) })从 chainableAssertions.ts 可以看到,attest内部正是把TypeAssertionMapping(如typeEqualityMapping)作为"可版本化实际值"交给assertEquals,从而做到跨 TypeScript 版本的一致断言。
其余值得注意的修复(0.46.0 与 0.51.0)
- 0.51.0:修复部分 tsconfig 路径解析问题(感谢 @LukeAbby 的贡献)。
- 0.46.0:修复某些 bench 文件解析不正确、导致报错或实例化计数为 0 的问题——这正是基准测试确定性目标的延续。
总结
从 0.7.x 到 0.51.0,@ark/attest的核心演进可以归纳为三条主线:
- 断言表达力的扩展:
equals→satisfies、type.toString正则匹配、type.errors、JSDoc 断言、补全快照,以及构造器级浅比较对 OOM 的防御; - 快照体验的工程化:prettier 多行格式化 +
typeToStringFormat、字母序补全快照、failOnMissingSnapshots(CI 感知默认值)、--updateSnapshots/ATTEST_updateSnapshots一键重建; - 类型性能基准的确定性:实例化数基准、基线表达式、阈值默认抛错、
stats/traceCLI 与 pnpm 解耦。
如果你想在现有 Vitest/Jest/Mocha 测试中引入类型级断言,或为自己的类型库建立跨版本、可量化的类型性能回归护栏,这套配置与源码路径(config.ts、assert/chainableAssertions.ts、bench、tests)就是最好的起点。
- 后端
【免费下载链接】arktype
TypeScript's 1:1 validator, optimized from editor to runtime
相关推荐
ArkType Attest 完全指南:在运行时断言 TypeScript 类型与性能基准
ArkType Attest 完全指南:在运行时断言 TypeScript 类型与性能基准 导读 Attest 是 ArkType 仓库中随 @ark/atte
后端Roc 语言 import exposing 类型导入语法深度解析:基于编译器快照测试的作用域与类型诊断实战
Roc 语言 import exposing 类型导入语法深度解析:基于编译器快照测试的作用域与类型诊断实战 本篇技术指南以 Roc 编译器仓库中的快照测试 t
深入 Roc 类型系统:用快照测试剖析嵌套类型变量的解析、规范化与类型推断
深入 Roc 类型系统:用快照测试剖析嵌套类型变量的解析、规范化与类型推断 导读 本文以 Roc 编译器仓库中的快照测试 test/snapshots/type
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考