ata-validator
1.61.3 · 29 Oct 2026MIT21.8k / weekRepositoryHomepagenpmAtom feed
173 stable releases, newest first, by release line. Notes are quoted as the maintainers published them.
1.61.3
29 Oct 2026Reading the note…
1.61.2
28 Oct 2026Reading the note…
1.61.1
26 Oct 2026Reading the note…
1.61.0
26 Oct 2026A schema with a
defaultanywhere (ata fills defaults without being asked, so it has a preprocess pass) now builds its rich errors where they fail, as other schemas do: the path was closed to schemas with a preprocess pass, though it reads the document validate() has already processed. Onerequirederror on a four-field user with a defaultednewsletterwent from 313 to 248 ns with its error list read; only the one-pass swap, which replaces validate() itself, stays off for such schemas. Found through the fastest-validator comparison in ata-core/validator-benchmarks (issue #74).The Node entry warms the engine once the event loop is free: where a validator was built and is still untouched by then (not compiled, no method read off it), one check of a small schema runs, accepted and rejected, so that V8 compiles the generator's functions and the error builders before the first request instead of on it. On a four-field schema with the generator already loaded, a server's first verdict went from 3.1 to 3.4 ms to 0.5 to 0.6, the first error list from 6.7 ms to 0.8, at 1.5 to 2 ms of CPU once. A script that builds and checks in the same tick never runs it, the timer is unref'd, and
ATA_NO_WARM=1turns it off. The browser build has done the same since 1.57.0 (lib/tiering.js); the cold-start benchmarks, which check in the same tick as they build, are unchanged by it.tests/test_node_warm.jsholds the conditions.ata-validator/buildexportsbunTarget(the schema as a TypeScript type and the.bun.tsmoduleata build --target bunwrites; withtags: truethe split into a type with Bun'sSchematags and a residual schema, made for the typed validator the compiler's pull request carried until 2026-10-25) andverify(verifyOne,verifySource,generate), for a build plugin that replacesnew Validator(schema)inside aBun.build({ compile: { aot: true } })and holds what it wrote against the runtime.
Added
A schema with a
defaultanywhere (ata fills defaults without being asked, so it has a preprocess pass) now builds its rich errors where they fail, as other schemas do: the path was closed to schemas with a preprocess pass, though it reads the document validate() has already processed. Onerequirederror on a four-field user with a defaultednewsletterwent from 313 to 248 ns with its error list read; only the one-pass swap, which replaces validate() itself, stays off for such schemas. Found through the fastest-validator comparison in ata-core/validator-benchmarks (issue #74).The Node entry warms the engine once the event loop is free: where a validator was built and is still untouched by then (not compiled, no method read off it), one check of a small schema runs, accepted and rejected, so that V8 compiles the generator's functions and the error builders before the first request instead of on it. On a four-field schema with the generator already loaded, a server's first verdict went from 3.1 to 3.4 ms to 0.5 to 0.6, the first error list from 6.7 ms to 0.8, at 1.5 to 2 ms of CPU once. A script that builds and checks in the same tick never runs it, the timer is unref'd, and
ATA_NO_WARM=1turns it off. The browser build has done the same since 1.57.0 (lib/tiering.js); the cold-start benchmarks, which check in the same tick as they build, are unchanged by it.tests/test_node_warm.jsholds the conditions.ata-validator/buildexportsbunTarget(the schema as a TypeScript type and the.bun.tsmoduleata build --target bunwrites; withtags: truethe split into a type with Bun'sSchematags and a residual schema, made for the typed validator the compiler's pull request carried until 2026-10-25) andverify(verifyOne,verifySource,generate), for a build plugin that replacesnew Validator(schema)inside aBun.build({ compile: { aot: true } })and holds what it wrote against the runtime.ata verify <glob>holds each schema's compiled module against a runtimeValidatorbuilt from the schema now: documents made from the schema (--generate, 200 by default, half with one rule broken), the project's sample documents (--samples, JSON or JSON Lines files or a directory), verdicts and error lists compared, a module whose schema hash no longer matches the schema reported stale, a non-zero exit on any disagreement, stale or missing module,--jsonfor machines. The check behind the fixes of 1.59.0 and 1.60.0, as a command a project runs in CI.ata build --target bunwrites a.bun.tsmodule beside the plain one for executables built withbun build --compile --aot: the schema as a TypeScript type (kinds, properties and which are required, items and fixed tuples, literals, local definitions as named types),isValidas a type guard andvalidatewith the type on them, andparse(text), which declares the type where it parses (under the pull request build of 2026-10-25 that is no faster than a plain parse and the module: 564 to 574 ns against 570 to 578 on a ten-item order, so the file is a type for the program, not a faster path); the module checks the content, so every answer is the module's. A project points the compiler at the file with an"aot"condition in itspackage.jsonimports, which nothing else reads. A schema no type can be written for (a remote$ref,$dynamicRef, a root the type says nothing about) gets a warning, an error with--strict, and the plain module alone. The compiler's pull request carried its own typed validator,validate<T>frombun:bundle, until 2026-10-25, and this target was first written for it: the schema split into a type with Bun'sSchematags and a residual schema of the rules the tags do not carry, which on 1024 distinct ten-item orders under the pull request build of 2026-10-24 answeredisValidin 96 to 99 ns against 108 for the plain module, and against the official suite never accepted a document the suite refuses (benchmark/proof/aot/suite-differential.mjs, 122 of 384 groups on 2020-12, 105 of 258 on draft 7). That validator left the pull request to return as a change of its own;bunTarget.schemaToBunType(schema, { tags: true })inata-validator/buildstill makes the split, and nothing in ata writes it until then.A validator built without
enginetakes the interpreted engine where the process runs code compiled ahead of time: when Bun reportsBun.isAOT, or whenATA_ENGINE=interpreteris set. Under Bun's ahead-of-time compiler (pull request build) the generated code is interpreted and the interpreted engine is compiled: 80 against 530 ns a document for the same schema, measured inbenchmark/proof/aot.engine: 'auto'by name keeps the generated code, and a module fromata buildis unaffected, since it is compiled either way (34 ns there).tests/test_aot_engine.js.schemaOf<T>(): a placeholder for the JSON Schema of a TypeScript type, written in at build time by@ata-project/unpluginfrom the type and the JSDoc tags on its properties, sonew Validator(schemaOf<User>())compiles like any static schema. It has no run-time form: a call that reaches the runtime throws with a message naming the plugin, rather than returning an empty schema that accepts everything. Exported from every entry.
Changed
The standalone module's error collector (the default form, with schema-source frames) reads each property once into a local, names a closed object's declared keys outright instead of a Set lookup per key when there are at most eight, and reuses the node's plain-object flag in that walk instead of reading the prototype again. A rejected ten-item order with its error list built went from 378 to 244 ns on Node (the one-pass form, 102, is unchanged and still opt-in for its size); the module's size does not move. Found by profiling line by line: one closedness walk held 31% of the samples.
ata build --target bunwrites the one-pass error function by default and no schema-source frames, as an executable compiled ahead of time pays no bundle size: a rejected ten-item order with the error list built went from 724 to 189 ns under Bun's compiler (on Node, 378 to 105 against 99 for the runtime). The default for every other build stays the older collector with frames, since the one-pass form is 20% larger gzipped over 60 SchemaStore schemas (53 of them larger by more than 5%, the worst 1.6x);--one-passand--no-sourcechoose it by hand. The plugin's ahead-of-time path does the same.Generated code walks and counts a closed object's keys in the form of the engine it is written for: for...in on V8, where it is the cheaper form, an indexed loop over
Object.keyson JavaScriptCore, where for...in is a slow path. The runtime writes for the engine it runs on;ata build --target bunand the plugin's Bun entry write for JavaScriptCore. One form is written, so neither engine pays a branch per object (an earlier form with a load-time flag cost V8 5 to 7% on small schemas). A third form,jsc-aot, is what--target bunand the plugin's ahead-of-time path write: a closed object whose declared keys are all required is checked by readingObject.keysonce and comparing it with the declared list in order, the orderJSON.parsegives, with a membership walk when the order differs. Under Bun's compiler that put the plain module level with Bun's ownvalidate<T>on a ten-item order (105 to 107 ns against 96 to 105, from 108 to 112); on Bun's JIT the same form loses 5 to 15 ns, which is why it is its own target. The prototype read picks__proto__on V8 andObject.getPrototypeOfon JavaScriptCore at generation. Measured on a ten-item order: Bun 1.4.4's JIT 65 to 53 ns a document, Bun's ahead-of-time compiler (pull request build) 227 to 108, Node 25 unchanged.
Fixed
CompiledModule.validateinata-validator/compileddeclareserrorsas read-only, which is what a module returns for an accepted document (a frozen array); under Bun's ahead-of-time compiler, where a TypeScript error fails the build, a compiled-away validator in a TypeScript file did not build.The standalone emitters required
lib/aot-order.jsandlib/error-runtime.jsthrough a name held in a constant, to keep bundlers for runtimes withoutfsfrom following them. An executable compiled ahead of time (Bun) cannot load a module from a computed path and warned about each such require at build time. They are literal requires now, and the package'sbrowserfield keepslib/aot-order.jsout of browser and edge bundles instead; the browser, edge and lite bundles and the Workers proof are unchanged. The native engine's loader still tries its optional packages by name, inside a try, which is one warning left under that compiler.
1.60.0
24 Oct 2026A validator the build compiled away (
fromCompiledaround a module written ahead of time, what@ata-project/unpluginputs in place ofnew Validator(schema)) carries Standard Schema V1 as the runtime Validator does: the same~standardobject, built bylib/standard-schema.jsfor both, with the same issues, value and published schema. A compiled validator can now be handed to a form library, a router or an RPC layer that reads~standard.tests/test_compiled_standard.jscompares the two.toStandaloneModule(schema, { coerceTypes })andtoStandaloneModule(validator)for a validator built withcoerceTypesorremoveAdditionalwrote a module that checked the data as it was, so it rejected{ id: "5" }where the validator accepts it. The module now runs the validator's pass over the data before the check, asbundleStandalonedoes since 1.59.0, and refuses where that pass cannot be written into a module.fastify-ata/standalonebuilds its files through this call.
Added
A validator the build compiled away (
fromCompiledaround a module written ahead of time, what@ata-project/unpluginputs in place ofnew Validator(schema)) carries Standard Schema V1 as the runtime Validator does: the same~standardobject, built bylib/standard-schema.jsfor both, with the same issues, value and published schema. A compiled validator can now be handed to a form library, a router or an RPC layer that reads~standard.tests/test_compiled_standard.jscompares the two.
Fixed
toStandaloneModule(schema, { coerceTypes })andtoStandaloneModule(validator)for a validator built withcoerceTypesorremoveAdditionalwrote a module that checked the data as it was, so it rejected{ id: "5" }where the validator accepts it. The module now runs the validator's pass over the data before the check, asbundleStandalonedoes since 1.59.0, and refuses where that pass cannot be written into a module.fastify-ata/standalonebuilds its files through this call.
Measured against 1.59.0
Node 25.2.1, interleaved runs from worktrees of both versions: cold compile, warm compile, hot validation with and without code generation, request costs and Fastify boot all within the noise of repeated runs; standalone module sizes unchanged. The browser runtime bundle grew by 82 bytes gzipped (136,709 to 136,791), the boundary of the shared Standard Schema module.
1.59.0
24 Oct 2026bundleStandalone(schemas, { order: true })hands each validator's errors out in the runtime's order, astoStandaloneModuledoes with the same option. rjsf's precompiled validator writes its module withbundleStandalone, and its error list showed anadditionalPropertieserror before thepropertieserrors where the runtime validator shows it after.tests/test_aot_order_defaults.jsholds 553 bundled groups of the 2020-12 and draft-07 suites to the runtime's errors, in order.Each validator of a bundle whose schema has a default carries
applyDefaults(data), the runtime's defaults pass, as a property, as standalone modules export it. rjsf's precompiled validator can call it to match its runtime validator.tests/test_bundle_preprocess.jscompares 8400 generated documents with the runtime: the data afterwards, the verdict and the errors in order.bundleStandaloneleftcoerceTypesandremoveAdditionalout without a word, so a bundle built withcoerceTypes: truerejected{ age: "5" }, which the runtime accepts. Each bundled validator now runs the runtime's pass over the data for those options before it checks, and withuseDefaults: truefills defaults the same way. Where the generated pass cannot carry what the runtime would do (coercing the items of an array at the root, for one),bundleStandalonethrows instead of writing a module that answers differently. Without these options a bundle checks the data as it is, as before.
Added
bundleStandalone(schemas, { order: true })hands each validator's errors out in the runtime's order, astoStandaloneModuledoes with the same option. rjsf's precompiled validator writes its module withbundleStandalone, and its error list showed anadditionalPropertieserror before thepropertieserrors where the runtime validator shows it after.tests/test_aot_order_defaults.jsholds 553 bundled groups of the 2020-12 and draft-07 suites to the runtime's errors, in order.Each validator of a bundle whose schema has a default carries
applyDefaults(data), the runtime's defaults pass, as a property, as standalone modules export it. rjsf's precompiled validator can call it to match its runtime validator.tests/test_bundle_preprocess.jscompares 8400 generated documents with the runtime: the data afterwards, the verdict and the errors in order.
Fixed
bundleStandaloneleftcoerceTypesandremoveAdditionalout without a word, so a bundle built withcoerceTypes: truerejected{ age: "5" }, which the runtime accepts. Each bundled validator now runs the runtime's pass over the data for those options before it checks, and withuseDefaults: truefills defaults the same way. Where the generated pass cannot carry what the runtime would do (coercing the items of an array at the root, for one),bundleStandalonethrows instead of writing a module that answers differently. Without these options a bundle checks the data as it is, as before.
Changed
Requiring the package no longer loads the code generator. It loads when a validator first compiles, or once the event loop is free, so a server reaches its first request without waiting for it. Importing the package went from 4.55 to 1.64 ms (Node 25, the median of 21 interleaved runs), and a server building 20 validators at startup from 4.71 to 1.83 ms. Validators still compile at their first call, and a script that validates as soon as it starts takes as long as before.
benchmark/bench_fastify_boot.mjsnow counts loading the library on both sides, as "from a cold process" says; the README's Fastify figure moves from 18.3 against 2.67 ms (loading left out) to 30.3 against 9.7 ms.
Measured against 1.58.1
Node 25.2.1, interleaved runs from worktrees of both versions. Unchanged within noise: cold compile, warm compile of 2000 schemas, hot validation with and without code generation, request costs, and standalone module sizes. The browser first load grew by 62 bytes gzipped (53,899 to 53,961), from the core's calls into the code generator loader that the Node entry uses; the loader itself lives in the Node entry. Fastify boot from process start to the first validated request: 9.7 ms either way when the request comes in the same tick, and 9.6 to 6.8 ms when it comes after an idle gap.
1.58.1
22 Oct 2026Reading the note…
1.58.0
21 Oct 2026- security
An object in
enumorconstcould be matched by a different object. The native engine compared objects through a canonical text that put each member name between quotes without escaping it, so{"a\":1,\"b": 2}, an object with one member, read the same as{"a": 1, "b": 2}and passedenum: [{ "a": 1, "b": 2 }]. Member names are now escaped as JSON strings, which makes the text one-to-one. The same text decideduniqueItems, where it made two different objects count as equal and rejected the array. - security
A violation deep enough in the document was accepted. Past a depth limit (100 schema frames, which a recursive schema reaches about 50 levels into the data) the native walker stopped and took the rest of the document as valid. It now rejects there, with the error code
depth_exceededin the C++ API, and the JavaScript APIs above send a schema that refers back into itself, or that is nested more than 40 levels, to the JavaScript engine, which has no such limit and answers asvalidate()does at any depth. That route was faster for such schemas, not slower: 4,638 to 2,495 ns forisValidon a 20-level document. - security
isValidParallelandcountValidread up to 64 bytes past the end of the caller's buffer for a line ending near it, andisValidPrepadded(buffer, length)read as many bytes aslengthsaid whatever the buffer's size. A line without 64 bytes of the buffer after it is now copied to a padded buffer first, andisValidPrepaddedreturns false for a length past the buffer and copies when the padding is short. The read did not fault, since the engine checks for a page boundary, and the bytes past the end did not decide the answer.
Security
These are in the optional native engine (
@ata-validator/native-*, or a build from source). Without it none of them applies. With it, the APIs that ask the native walker areisValid,isValidPrepadded,isValidNDJSON,isValidParallel,countValid,batchIsValid, andvalidateJSON/isValidJSONon documents of 8 KB or more;validate()andisValidObject()never ask it.- security
An object in
enumorconstcould be matched by a different object. The native engine compared objects through a canonical text that put each member name between quotes without escaping it, so{"a\":1,\"b": 2}, an object with one member, read the same as{"a": 1, "b": 2}and passedenum: [{ "a": 1, "b": 2 }]. Member names are now escaped as JSON strings, which makes the text one-to-one. The same text decideduniqueItems, where it made two different objects count as equal and rejected the array. - security
A violation deep enough in the document was accepted. Past a depth limit (100 schema frames, which a recursive schema reaches about 50 levels into the data) the native walker stopped and took the rest of the document as valid. It now rejects there, with the error code
depth_exceededin the C++ API, and the JavaScript APIs above send a schema that refers back into itself, or that is nested more than 40 levels, to the JavaScript engine, which has no such limit and answers asvalidate()does at any depth. That route was faster for such schemas, not slower: 4,638 to 2,495 ns forisValidon a 20-level document. - security
isValidParallelandcountValidread up to 64 bytes past the end of the caller's buffer for a line ending near it, andisValidPrepadded(buffer, length)read as many bytes aslengthsaid whatever the buffer's size. A line without 64 bytes of the buffer after it is now copied to a padded buffer first, andisValidPrepaddedreturns false for a length past the buffer and copies when the padding is short. The read did not fault, since the engine checks for a page boundary, and the bytes past the end did not decide the answer. - security
isValidParallelcould return false for a valid line: its workers wrote their answers into astd::vector<bool>, which packs eight lines to a byte, so neighbouring lines raced. One byte per line now; 20 runs of 200 lines all agree withvalidate().
tests/test_native_security.jsholds each one through the addon and through the public APIs, andtests/test.cppthe first two in the C++ API.singleheader/is regenerated from the current sources.Changed
defaultvalues are filled in every schema applied to a value, as the default validator'suseDefaultsfills them: behind a$refinto the same document (recursive ones included), in eachallOfmember, in array elements underitemsandprefixItems, underadditionalPropertiesandpatternPropertiesfor the keys they cover, and underdependentSchemasfor the keys present. The pass stopped atproperties, so arequiredproperty whose default sat behind a reference or in an array element was rejected where the default validator accepts it.anyOf,oneOfandnotfill nothing, as there.if/then/elsefill nothing here either, since which branch applies is only known once the value has been checked; the default validator fills some of those cases. On SchemaStore's sample documents for the 372 schemas with defaults it compiles (1,287 documents), the filled data differs from the default validator's on 161 documents, down from 488, and the verdict on 23, down from 42; every remaining difference is in a schema withoneOf,anyOf,ifornot. All three passes fill the same: the generated one, the closure one (the interpreted engine, and where the generated pass declines a schema), and a module'sapplyDefaults, which now embeds the closure pass where the generated one declines: 405 of 878 SchemaStore modules exportapplyDefaults, up from 289.tests/test_defaults_reach.jscovers each place, against values taken from the default validator, and 300 generated schemas on all three passes.This can reject a document that was accepted before, when a schema's own default breaks one of its rules. Of SchemaStore's 1,537 positive samples, 17 are now rejected; the default validator with
useDefaultsrejects 16 of them. The 17th fillslayout: truewhere a siblingthensaysnot: { required: ["layout"] }: the default validator checksallOfbefore it fillsproperties, ata fills first.useDefaults: falseleaves the input as it is.Inside a value the pass made from a default, a schema already applied to an enclosing value is not applied again. A schema whose object default refers back to an enclosing one would otherwise fill without end; Renovate's schema did, and the default validator throws a
RangeErroron it. An empty Renovate configuration fills to 22,774 bytes, as before.A validator whose schema has defaults, or that coerces, builds its errors when they are read, as other validators do. It built them on every rejection, read or not: on Kestra's schema that was 25 ms of the first
validate()spent ordering errors nobody asked for. A rejection on a small schema with defaults went from 46 to 22 ns when the errors are not read.A schema whose defaults would expand past the generated pass's size limit is handed to the closure pass before any code is written. SARIF's schema wrote 4,000 lines in 2.7 ms and threw them away. The generated source is unchanged on all 1,692 SchemaStore cases checked.
In the browser build, the plan compiler, which turns a schema the interpreted engine checks often into a tree of closures, is no longer in the page's first load. It arrives through a dynamic import, a chunk of its own in a bundler that splits, once the page is idle after its first validator (
lib/interpreter.browser.js, which the package.json browser field puts in place oflib/interpreter.js). Until then the plain walk answers, as it already did for each schema's first thousand calls, with the same answers and the same depth of data before the stack runs out. The first load is 53,953 bytes gzipped, from 58,245 in 1.57.0. In Chrome with the CPU slowed 4x and a 1.6 Mbps link, a react-hook-form page withataResolverwas ready in 1,594 ms instead of 1,672 (1,668 for a second copy of 1.57.0); the first submit 300 ms after the form was built went from 2.50 to 2.25 ms, and a submit before the page was idle did not move (17.43 and 16.86 before, 16.92 after). With code generation blocked, a hot validator answers as before once the chunk is in (49.5 ns a rejection, both).tests/test_browser_plan_lazy.jscovers the split and the answers before and after.
Measured on the 564 SchemaStore schemas with a default, constructing each and validating its first sample: 1,462 to 1,327 ms in total, and with code generation blocked 934 to 991 ms. The blocked figure is the closure pass filling what it now reaches: a 400 KB SARIF log gains 4,130 values. The per-schema gate over SchemaStore and the request gate show no change beyond noise.
Size
The browser build's first load is 4,292 bytes smaller gzipped (58,245 to 53,953): the defaults pass and its guard added 1,077 and moving the plan compiler out took 5,369. The plan compiler's chunk is 6,334 bytes.
ata-validator/liteis 57,380 bytes gzipped (up 1,213) where it is bundled without splitting, astests/test_lite_bundle_size.jsdoes: the plan compiler stays in, and the code that loads it is added. With a bundler that splits, its first load is 51,839, from 56,277 in 1.57.0. The Workers bundle is 58,577 (up 1,013).The package is 470.3 KB packed.
1.57.0
20 Oct 2026In the browser build, a page's first check no longer pays for V8 compiling the engine, nor for building the schema's engine. Both move to when the page is idle (
requestIdleCallback, or 200 ms later where there is none, as in Safari and in workers): the first validator a page builds schedules one check of a small schema of ata's own, once per page, and then the engine for each validator built since, at most eight, which a validator adopts at its first check only if every input is still the same, its schema text included.addSchema()or a change to the schema object between construction and the first check is honored as before, a schema with custom keywords is left to its first check, since their compile functions are the caller's code, and nothing on a validator changes until it is used. The warm-up is dropped when a validator has compiled before the page is idle. Measured in Chrome with@hookform/resolverson an eight-field form, the first submit 300 ms after the form was built went from 6.43 to 0.88 ms, and from 18.56 to 2.48 ms with the CPU slowed 4x as Lighthouse does for mobile. A submit before the page has been idle went from 3.97 to 3.66 ms, and from 18.93 to 17.20 ms with the CPU slowed. Node, lite and edge do not carry the idle work.tests/test_browser_warmup.jscovers the warm-up, the adoption and each case where an engine built while idle must not be used.The interpreted engine gives a pattern to the platform RegExp when it has no group or alternation, few bounded choices in all, and at most one unbounded repeat, on one character or class and only when the pattern starts with
^: such a pattern runs in linear time on a backtracking engine, as the code generator already decided for these patterns (lib/regex-linear.js). The linear engine took 40 to 100 us to build a pattern's automaton, a RegExp about one. On lite, a form's first check went from 3.83 to 3.49 ms and a valid document from 205.8 to 193.0 ns.tests/test_regex_plain_linear.jsholds that every pattern taking the RegExp gets one from the code generator too, so both engines answer with the same matcher, and that the answers equal the linear engine's on generated strings.lib/rejections.jsand the schema order and diagnostic modules it brings load at the first compile, where they are first used, rather than when the package loads. Importing the browser build went from 4.41 to 4.36 ms in Node.
Changed
In the browser build, a page's first check no longer pays for V8 compiling the engine, nor for building the schema's engine. Both move to when the page is idle (
requestIdleCallback, or 200 ms later where there is none, as in Safari and in workers): the first validator a page builds schedules one check of a small schema of ata's own, once per page, and then the engine for each validator built since, at most eight, which a validator adopts at its first check only if every input is still the same, its schema text included.addSchema()or a change to the schema object between construction and the first check is honored as before, a schema with custom keywords is left to its first check, since their compile functions are the caller's code, and nothing on a validator changes until it is used. The warm-up is dropped when a validator has compiled before the page is idle. Measured in Chrome with@hookform/resolverson an eight-field form, the first submit 300 ms after the form was built went from 6.43 to 0.88 ms, and from 18.56 to 2.48 ms with the CPU slowed 4x as Lighthouse does for mobile. A submit before the page has been idle went from 3.97 to 3.66 ms, and from 18.93 to 17.20 ms with the CPU slowed. Node, lite and edge do not carry the idle work.tests/test_browser_warmup.jscovers the warm-up, the adoption and each case where an engine built while idle must not be used.The interpreted engine gives a pattern to the platform RegExp when it has no group or alternation, few bounded choices in all, and at most one unbounded repeat, on one character or class and only when the pattern starts with
^: such a pattern runs in linear time on a backtracking engine, as the code generator already decided for these patterns (lib/regex-linear.js). The linear engine took 40 to 100 us to build a pattern's automaton, a RegExp about one. On lite, a form's first check went from 3.83 to 3.49 ms and a valid document from 205.8 to 193.0 ns.tests/test_regex_plain_linear.jsholds that every pattern taking the RegExp gets one from the code generator too, so both engines answer with the same matcher, and that the answers equal the linear engine's on generated strings.lib/rejections.jsand the schema order and diagnostic modules it brings load at the first compile, where they are first used, rather than when the package loads. Importing the browser build went from 4.41 to 4.36 ms in Node.A module written by
toStandaloneModule,ata compileorata buildexportsapplyDefaults(data)when its schema declares adefault: the runtime's defaults pass (preprocessSourceinlib/codegen-engine.js), which fills whatnew Validator(schema).validate()fills before it checks.validate()does not call it, so a module answers as before, andfromCompiledkeepsuseDefaults: false. The generated.d.mtsdeclares it. Over SchemaStore, 278 of 854 modules gain it and the other 576 are byte for byte what they were.toStandaloneModule(schema, { order: true }),ata compile --orderandata build --orderwrite a module that hands out its errors in the runtime's order, schema declaration order, instead of the order its collector found them in, which put a field'sformaterror before itspatternerror and, behind a$ref, a definition'srequiredbefore itsproperties, so a reader taking the first error of a field could show another message than the runtime. Without a$refthe collector writes each error site's ordinal and the module sorts by it; with one the module ranks paths withlib/schema-order.js's own functions, embedded as their text bylib/aot-order.js, over the schema's key skeleton. Off by default, since it costs bytes: 225 more gzipped, minified, for an eight-field form, about 1 KB for a small schema with references, 12.6% over 267 SchemaStore schemas. WithorderandapplyDefaults, modules matched the runtime's verdict, filled data and error list in order on every case of the official suites they compile (1,500 groups, 5,324 cases across six dialects) and on 1,952 SchemaStore sample documents. Modules that share the error runtime were in this order already.compiledModuleFor(schema, { order: true })passes it on, for a bundler plugin writing a module a form resolver reads directly.tests/test_aot_order_defaults.jscovers both.
Size
The browser build's first load is 1,095 bytes larger gzipped (57,059 to 58,154), for the idle work and the pattern check. With the CPU slowed 4x and a 1.6 Mbps link, the time from navigation until the form's resolver was ready did not move beyond noise (1,664.8 ms before, 1,666.7 after, against 1,658.9 for a second copy of the old build).
The package is 453.2 KB packed, for
lib/aot-order.jsand the emitter changes.ata-validator/liteis 56,167 bytes gzipped, up 419, and the Workers bundle 57,564, up 413, for the pattern check, the core's hook for the idle work and the core's internals the module emitters read.
1.56.0
18 Oct 2026The browser build (the
browserexport condition, which Vite, webpack, Rollup and esbuild use for a page) no longer puts the code generator in the page. Every schema starts on the interpreted engine, and a validator called a thousand times within a second loads the generator through a dynamic import, which bundlers split into a chunk of its own, and is rebuilt on generated code in place, one validator per idle task, so that several turning hot together do not hold the main thread (lib/tiering.js). Calls spread out further, a form checked as the user types, never load it: there generated code would save tens of nanoseconds a check against about 6 ms to load the generator and compile. Once rebuilt, a validator is as fast as one compiled from the start (16 ns a check on a form schema, both ways). The answers are the same on both engines, valid and errors alike. On a react-jsonschema-form page the validator's share of the first load goes from 134.7 KB to 57.5 KB gzipped with esbuild's splitting (205.9 KB for the whole page with Vite, 280.2 before); the generator chunk is 77 to 80 KB when it loads. A bundler that does not split chunks puts it back in the page, as before. Where code generation is blocked the chunk is never fetched.ata-validator/codegenregisters the generator at once, for a page that wants generated code from the first call.The docs no longer point pages under a strict Content-Security-Policy or Manifest V3 extensions to
ata-validator/lite: the browser build checks whether generated code can run before it fetches the generator, soata-validatorcovers them.ata-validator/litestays and works as before for code that imports it.
Changed
The browser build (the
browserexport condition, which Vite, webpack, Rollup and esbuild use for a page) no longer puts the code generator in the page. Every schema starts on the interpreted engine, and a validator called a thousand times within a second loads the generator through a dynamic import, which bundlers split into a chunk of its own, and is rebuilt on generated code in place, one validator per idle task, so that several turning hot together do not hold the main thread (lib/tiering.js). Calls spread out further, a form checked as the user types, never load it: there generated code would save tens of nanoseconds a check against about 6 ms to load the generator and compile. Once rebuilt, a validator is as fast as one compiled from the start (16 ns a check on a form schema, both ways). The answers are the same on both engines, valid and errors alike. On a react-jsonschema-form page the validator's share of the first load goes from 134.7 KB to 57.5 KB gzipped with esbuild's splitting (205.9 KB for the whole page with Vite, 280.2 before); the generator chunk is 77 to 80 KB when it loads. A bundler that does not split chunks puts it back in the page, as before. Where code generation is blocked the chunk is never fetched.ata-validator/codegenregisters the generator at once, for a page that wants generated code from the first call.The docs no longer point pages under a strict Content-Security-Policy or Manifest V3 extensions to
ata-validator/lite: the browser build checks whether generated code can run before it fetches the generator, soata-validatorcovers them.ata-validator/litestays and works as before for code that imports it.In the browser build,
parse()waits for the generator: before it has loaded, it throws aTypeErrorthat namesata-validator/codegen, and once it has, it works. The ahead-of-time bundle methods were already left out of the browser build and still point toata-validator/aot.tests/test_browser_tiering.jscovers the switch, a cold validator, blocked code generation and the eager import.
Size
ata-validator/liteis 55.7 KB gzipped, up 0.2 KB for the core's hooks for the on-demand generator, which lite does not carry.
Not changed
Node, the edge entry (
workerd,edge-light) andata buildmodules work as in 1.55.0. On Node the regression gate shows no group outside noise, and SchemaStore gives the same verdicts on 31,822 documents.
1.55.0
17 Oct 2026bundleStandaloneandbundleCompactmodules threwReferenceError: _cpLen is not definedon a string whoseminLengthormaxLengthneeded counting code points: a one-character value underminLength: 1, an emoji under amaxLength. The length checks call a code-point counter thattoStandaloneModuleembeds and the two bundles did not. This goes back to 1.10.0 at least, and reached forms built on rjsf's precompiled validator, which usesbundleStandalone: typing one character into a required name field threw. The bundles now embed the counter when their code calls it;tests/test_bundle_cplen.jsruns both bundles in both formats.The interpreted engine, which answers where code generation is blocked (Cloudflare Workers, Deno Deploy, a strict Content-Security-Policy), does the same work in fewer calls. A leaf of one type and only that type's keywords (
{ type: 'string', maxLength: 80 },{ type: 'integer', minimum: 1 }) is one closure overtypeofand the check, with no type bits computed first; an object's value checks run inside its property step instead of beside it; and a required key that is also a declared property is counted while the properties are walked, not looked up again. A declared property of an ordinary object (one whose prototype isObject.prototype) is read directly where its name is not on that prototype, instead of after an own-property test; a key such asconstructorortoString, and any other object, keep the test. An enum of primitive values is one Set lookup, and aminLengthwith amaxLengthis one closure that decides most strings from their UTF-16 length. On the request benchmark with code generation blocked, against 1.53.0: accepting the body 240 to 155 µs, rejecting it 106 to 73, the error list read 577 to 506; the hot loop 195 to 142 ns a document. A hand-written closure tree for the same schema measures 136 ns, so the engine is within 5% of what closures can do; the generated code's 20 ns is a different mechanism.ata-validator/lite, which is this engine, grows from 54.8 to 55.5 KB gzipped for it.tests/test_plan_compiles.jsholds the closure-tree compiler to every schema of the official suite, since the engine quietly falls back to its plain walk when the compiler throws, and did so for a while during this work with every test green;ATA_DEBUG=1now reports that fallback.A compact module (
ata build --errors auto,toCompactModule, the bundler plugin'ssharedErrors) takes the RFC 3986 walk behind the URI formats from the error runtime it already imports (uriWalk), instead of carrying the factory's text. 1.54.0 put that text, about 1.3 KB gzipped, in every module with auri,uri-reference,irioriri-referencefield: forty SchemaStore schemas built that way went from 213.3 to 224.9 KB gzipped, and measure 217.9 now (the rest is the 1.54.0 email and record-list code, which stays). A self-contained module still carries it, since it imports nothing.ata-validator/error-runtimeexportsuriWalkfor the same reason; a compact module written by 1.55 needs that export, so build and runtime come from one version, as before.
Fixed
bundleStandaloneandbundleCompactmodules threwReferenceError: _cpLen is not definedon a string whoseminLengthormaxLengthneeded counting code points: a one-character value underminLength: 1, an emoji under amaxLength. The length checks call a code-point counter thattoStandaloneModuleembeds and the two bundles did not. This goes back to 1.10.0 at least, and reached forms built on rjsf's precompiled validator, which usesbundleStandalone: typing one character into a required name field threw. The bundles now embed the counter when their code calls it;tests/test_bundle_cplen.jsruns both bundles in both formats.
Changed
The interpreted engine, which answers where code generation is blocked (Cloudflare Workers, Deno Deploy, a strict Content-Security-Policy), does the same work in fewer calls. A leaf of one type and only that type's keywords (
{ type: 'string', maxLength: 80 },{ type: 'integer', minimum: 1 }) is one closure overtypeofand the check, with no type bits computed first; an object's value checks run inside its property step instead of beside it; and a required key that is also a declared property is counted while the properties are walked, not looked up again. A declared property of an ordinary object (one whose prototype isObject.prototype) is read directly where its name is not on that prototype, instead of after an own-property test; a key such asconstructorortoString, and any other object, keep the test. An enum of primitive values is one Set lookup, and aminLengthwith amaxLengthis one closure that decides most strings from their UTF-16 length. On the request benchmark with code generation blocked, against 1.53.0: accepting the body 240 to 155 µs, rejecting it 106 to 73, the error list read 577 to 506; the hot loop 195 to 142 ns a document. A hand-written closure tree for the same schema measures 136 ns, so the engine is within 5% of what closures can do; the generated code's 20 ns is a different mechanism.ata-validator/lite, which is this engine, grows from 54.8 to 55.5 KB gzipped for it.tests/test_plan_compiles.jsholds the closure-tree compiler to every schema of the official suite, since the engine quietly falls back to its plain walk when the compiler throws, and did so for a while during this work with every test green;ATA_DEBUG=1now reports that fallback.A compact module (
ata build --errors auto,toCompactModule, the bundler plugin'ssharedErrors) takes the RFC 3986 walk behind the URI formats from the error runtime it already imports (uriWalk), instead of carrying the factory's text. 1.54.0 put that text, about 1.3 KB gzipped, in every module with auri,uri-reference,irioriri-referencefield: forty SchemaStore schemas built that way went from 213.3 to 224.9 KB gzipped, and measure 217.9 now (the rest is the 1.54.0 email and record-list code, which stays). A self-contained module still carries it, since it imports nothing.ata-validator/error-runtimeexportsuriWalkfor the same reason; a compact module written by 1.55 needs that export, so build and runtime come from one version, as before.
Size
ata-validator/liteis 55.5 KB gzipped, up from 54.8 in 1.54.0, for the interpreted engine's fused checks. Compact modules that check a URI format are about 1.3 KB smaller each.
1.54.0
15 Oct 2026The
uri,uri-reference,iriandiri-referenceformats follow RFC 3986 in full. They checked the characters and, foruri, the shape of the authority; a bracketed host was not read, sohttp://[::ffff:01.2.3.4]passed, anduri-referencechecked only the characters, so//example.com:abc/p,//a@b@example.com/,/[::1]and1:bpassed. One walk now parses scheme, authority (userinfo, a host that is a name, an IPv6 literal with its embedded IPv4, an RFC 6874 zone id or an IPvFuture, and a digits-only port), path, query and fragment, and rejects brackets outside the host, a colon in the first segment of a relative path and a second#. It lives inlib/uri-core.jsand is the same text in the interpreted engine, generated code and standalone modules. Theuriexpression in front of it now takes a host with a port, which most of SchemaStore's misses were (http://localhost:5000, 73 to 34 ns in generated code), and a reference made only of plain characters, a file name or a path, is answered by one scan before the walk, so those checks cost what the character scan did. On the 26321 strings in SchemaStore's sample documentsuriandirigive the same answers as before;uri-referencenow rejects 346 of them, every one a string that is not a URI reference ([string],00:00:01,2022-01-19T22:02:33Z), 277 of which the default validator still accepts.uri-template: a%in a literal or a variable name must start a percent-encoded triplet (a%,a%4,a%GG,{%}were accepted).idn-email: a domain label that starts or ends with a hyphen is refused, asemailrefuses it (the official suite's 7de0e6a adds these cases).
Fixed
The
uri,uri-reference,iriandiri-referenceformats follow RFC 3986 in full. They checked the characters and, foruri, the shape of the authority; a bracketed host was not read, sohttp://[::ffff:01.2.3.4]passed, anduri-referencechecked only the characters, so//example.com:abc/p,//a@b@example.com/,/[::1]and1:bpassed. One walk now parses scheme, authority (userinfo, a host that is a name, an IPv6 literal with its embedded IPv4, an RFC 6874 zone id or an IPvFuture, and a digits-only port), path, query and fragment, and rejects brackets outside the host, a colon in the first segment of a relative path and a second#. It lives inlib/uri-core.jsand is the same text in the interpreted engine, generated code and standalone modules. Theuriexpression in front of it now takes a host with a port, which most of SchemaStore's misses were (http://localhost:5000, 73 to 34 ns in generated code), and a reference made only of plain characters, a file name or a path, is answered by one scan before the walk, so those checks cost what the character scan did. On the 26321 strings in SchemaStore's sample documentsuriandirigive the same answers as before;uri-referencenow rejects 346 of them, every one a string that is not a URI reference ([string],00:00:01,2022-01-19T22:02:33Z), 277 of which the default validator still accepts.uri-template: a%in a literal or a variable name must start a percent-encoded triplet (a%,a%4,a%GG,{%}were accepted).idn-email: a domain label that starts or ends with a hyphen is refused, asemailrefuses it (the official suite's 7de0e6a adds these cases).email: the inside of a quoted local part is checked ("test\©"@iana.orgwas accepted, a quoted pair takes one printable ASCII character), and theIPv6:tag of an address literal is read in any case (a@[ipv6:::1]was rejected).A node without
type: objectchecked an array against itsproperties:[1, 2]was rejected by{ properties: { length: { maximum: 1 } } }, through the array's ownlength, while the interpreter and the default validator accept it. Generated code now skips arrays there when a property name is one an array can own (lengthor an index); other names were already safe, since an array only inherits them. A required key on such a node is also read before it is asked whether it is the object's own, which saves a test.tests/test_untyped_properties.jscovers both on 189 documents.Draft-04 is read as draft-04. A schema that declared it was read as 2020-12:
iddid not set the base URI, tupleitems,additionalItemsanddependencieswere not applied, andconst,containsandpropertyNames, which draft-04 does not have, were. It now goes through the draft-06 rules plus its own (idfor$id, those three keywords dropped), and its meta-schema is vendored with the others. The official suite's draft-04 tests pass, 618 of 618, also with code generation blocked;npm run test:suiteruns them. SchemaStore's draft-04 schemas answer their sample documents as before.A declared property holding
undefinedstill got different answers in 1.53.0: the generated code checked the value where the key was required, where the node had notype, and insideif,notand similar subschemas, and the engines disagreed on whether a required key holdingundefinedwas there. Every engine now reads such a key as the default validator does, and as it reads once serialized: absent. Its value is not checked, and a required key holding it is missing, so{ a: undefined }no longer passesrequired: ['a']on any engine.{ if: { properties: { t: { const: 'x' } } }, then: false }accepted{ t: undefined }on generated code and rejected it on the interpreter; both reject it now.tests/test_undefined_property.jscovers 21 shapes on every engine, with and without code generation.
Changed
Two schemas that generate the same code no longer slow each other down. V8 keeps one compiled function per source text of a
new Functioncall, with one set of inline caches, so two copies of a record schema under different$ids, each validating lists of a different shape, ran each other's caches megamorphic: 500 records took 2.8 us alone and 18.0 with the twin in the same process. Every program compiled at run time now opens with a comment that differs per compile, 2.8 us either way. Standalone builds carry no such comment, so a build is the same text whatever was compiled before it.Reading the errors of a list of records with many optional properties, where the records do not share one shape, took 20.6 ms for 2000 records and now takes 0.57. The error program guards each record with a verdict and collects only the one that fails; the guard for a 150-property record was written one read per property, 16.9 KB, over the size the program accepts, so there was no guard and every record was collected in full with reads that change shape at each one. The guard now takes the key-loop form the verdict uses for such records, 10 KB, and runs only on a list whose first, middle and last records list different keys: records of one shape are read as before (16 us for the same 2000). On the way the key loop's switch, pushed as separate lines, read
switch(k){;caseonce joined with;, a syntax error that made the rich error program fall back to the plain one without a word; it is one line now andtests/test_rich_key_loop_guard.jsholds the compile.A oneOf under
type: objectwrites its branches for an object already checked, without the type test and the second own-key read each branch made for its required keys (vercel's now.json spends 23% of a validation in two such branches). A branch reached where the type is not known keeps its test, as a separate function.A pattern that only names keys,
^cdn$or^(cdn|Cdn)$, is a string comparison in generated code, and.*is no test at all. appsettings carries eight such patterns and answered each with a regex run per key; its samples validate 15% faster.tests/test_pattern_literal_keys.jsholds the shapes that must still be a regex (^a|b$,^(a|b)c$, a$not at the end).The linear regex engine's matcher loop reads its state tables through locals and reads one flag per character for both a match and a dead state. The tables live in the matcher's closure and were reloaded on every character, about a third of the loop; SchemaStore's popxf-1.0, whose eleven patterns all run on this engine, validates its samples 18% faster. The engine's answers are unchanged (88000 random strings against the platform RegExp, 0 differences).
A definition on a reference cycle that refers back to the root (
$ref: "#") compiles. Such a definition is a function above the entry, where the entry is not in scope, so the schema declined code generation and ran on the interpreter: SchemaStore's jsone, the one schema with sample documents that did, was 0.60x the default validator's speed. The definition now reaches the root through a forwarder the entry fills in, named per compile, so two such schemas in one bundle do not share it.tests/test_def_root_forwarder.jsholds generated code, a standalone module and a two-schema bundle to the interpreter's answers.An array of records with 32 or more optional properties is checked one of two ways per call. When three of its records (first, middle, last) list their keys alike, which records of one shape do, each declared property is read as before; when they do not, each record is checked from its own keys. Reading every declared property is fastest while the records share a shape and slowest once their shapes vary, because V8's property reads then stop being specialised. On 2000 records of 150 typed fields, records that each carry a different handful of keys go from 20.13 ms to 0.29 ms an array, and records of one shape from 7.41 to 7.19 µs.
tests/test_item_key_loop.jsholds both forms to the interpreter's answers on 648 arrays. Error lists for such arrays are still built the old way.A
patternPropertiesentry whose schema checks nothing ({}ortrue), as OpenAPI's^x-extensions are on nearly every object, no longer writes a loop over the object's keys: the loop read each matching key's value and did nothing with it. A valid OpenAPI 3.1 document goes from 4.49 to 3.47 µs and a rejected one with its errors read from 3.73 to 3.35 µs; the first validation is unchanged (10.82 against 10.81 ms, median of 21). A pattern that still checks something, apropertyNamesandadditionalPropertieskeep the loop.OpenAPI 3.1's own schema runs on generated code. It has a property named
exampleswhose schema is a$ref, and the passes that rewrite references skippedexamples,default,enumandconstas value keywords even where they are property names, so the copy pointed at a definition it no longer had and the whole schema fell back to the interpreted engine. A valid OpenAPI document goes from 9.81 to 4.62 µs, and to 3.47 µs with the next entry, a rejected one with its errors read from 14.06 to 3.85 µs; the first validation is unchanged (10.83 to 10.69 ms).tests/test_name_map_value_keys.jsholds those four names as properties, through$dynamicRefand through a reference into another document, to generated code and to the interpreter's answers.Reading a rejected document's errors costs about half what it did when the list has more than one error. The
detailtext is worked out where each error site is compiled, as far as the site allows, instead of from the keyword at every failure, and the list built where the errors happen says whether it came out in schema order, so it is sorted only when it did not. A migration-style schema with two errors goes from 159.9 to 79.6 ns a document; the request benchmark's rejected body with its error list read from 135.3 to 96.1 µs against 1.53.0. The errors are unchanged:tests/test_rich_errors_generated.jscompares them withenrich()on 10,553 rejections, and SchemaStore's 26,563 rejections keep their order with the order check on.A fresh process loads about 68 KB less before its first answer: the draft-07 and 2019-09 normalizers, the
$vocabularyreader, the runtime format functions and the small-schema tier load where they are first used. A 2020-12 schema's first validated document comes 6.9% sooner than with 1.53.0 (7.56 to 7.04 ms, median of 41 processes); a draft-07 schema that needs normalizing, which still loads them, 7.1% sooner (7.12 to 6.62 ms).A definition that refers to itself on the same value through
allOforanyOf(X: { allOf: [{ $ref: X }] }) no longer sends the whole schema to the interpreted engine. SchemaStore's cloudify, 284 KB, goes from 38 to 9.1 µs a document, against 10.6 for the default validator. Anot,if,thenorelsesubschema is a function written once instead of a closure made on every call, and is told whether the value is an ordinary object instead of reading its prototype again: minecraft-predicate's 25 if/then pairs go from 126 to 99 ns. A recursive anyOf or oneOf branch is written in place instead of as a closure built on every call: jsonld from 61 to 46 ns.uniqueItemsandconst/enumon objects compare keys and scalar values before nested ones, from the last key back, so two items that differ in a name or an id are told apart without walking their nested lists first. rust-project's crate list goes from 301 to 126 ns a document, against 160 for the default validator. The generated code's copy and the interpreted engine's stay one function, held bytests/test_unique_items_error_pair.js.A schema that refers to itself, or holds a definition on a cycle, declares its guarded functions once instead of on every call: the verdict and the forms validate() builds from it made three closures per document. On SchemaStore's samples component.json goes from 52 to 40 ns a document and jsonld from 46 to 38, against 52 and 29 for the default validator.
An enum of 64 or more strings is checked with one Set lookup instead of a comparison per value: jscpd's 224 values go from 1462 to 237 ns a document, cryproj's 2461 from 1955 to 69. Below 64 the comparisons stay, since on real documents they were faster. A single-branch anyOf or oneOf is written as its branch, or not at all when an allOf beside it applies the same
$ref. On SchemaStore's devinit schemas, whose items read{ allOf: [{ $ref: R }], anyOf: [{ $ref: R }] }, a document goes from 2.29 to 1.10 µs (devinit 4.0), 2.12 to 1.04 (3.0) and 2.07 to 0.92 (1.0), against 2.10, 1.97 and 1.91 for the default validator.^[class]+$,^[class]{m,n}$and longer^[class]{n}$patterns over ASCII letters, digits,_and-are checked by a function that reads each character once and looks it up in a 128-entry table, instead of comparing it against each range in an arrow function built per call. component.json's verdict goes from 30.5 to 26.9 ns, amx-muse's from 28.8 to 25.2 (the default validator: 29.8 and 28.8).More patterns run on the platform RegExp instead of the linear-time engine, which costs three to five times as much per character. A loop whose every iteration ends with a character nothing else in it matches (
^([a-zA-Z_$][\w$]*\.)*[a-zA-Z_$][\w$]*$,^/?([^/]+/)*[^/]+/?$) and a pattern with one bounded repeat that can vary (^[a-z0-9_-]{1,128}$) are linear on a backtracking engine too. Eight of SchemaStore's 1237 patterns move; they answer as the linear engine does on 24,000 inputs and take under a millisecond on inputs built to make a backtracking engine retry. bungee-plugin goes from 134 to 80 ns a document, imgbotconfig from 147 to 89, apple-app-site-association from 489 to 310.The browser bundle no longer carries what only
ata-validator/compatuses: coercion where eachtypeis checked and the discriminator are in their own file, which compat installs, and the schema scan's compat questions are asked only when compat is loaded. An app that imports onlyValidatorgoes from 128.5 to 127.6 KB gzipped with esbuild,ata-validator/litefrom 54.8 to 53.8 KB. compat behaves as before: express-openapi-validator's suite still passes, and verdicts and coerced data still match the reference on 2400 generated documents.
Size
Measured against 1.53.0, gzip. The full
Validatorbrowser bundle (index.browser.mjs) is 131.9 KB, up from 128.5: the generators that build errors where they fail, the key-loop forms, the per-compile tag and the URI walk all land in it. A standalone module that checks auriis 2.7 KB, up from 2.1, since the walk replaced a character scan; one that checks anemailis 2.3 KB, up from 2.2.ata-validator/liteis 54844 bytes, 1.53.0 was 54767: 77 bytes, the URI walk against the character scans it replaced. The core tarball is 450 KB packed, 439 in 1.53.0, most of it the draft-04 meta-schema.idn-hostnameand thexn--half ofhostnameare still not asserted; a Punycode and label-rule check was written and measured at 1.2 KB on lite and 1.4 KB on every standalone module with an email or hostname, and held back for that cost.
1.53.0
11 Oct 2026Custom keywords can ask for their data's context with
dataContext: true: the per-value function is also given{ instancePath, parentData, parentDataProperty, rootData }, as the default validator's keywords are, so it can read or remove the value where it sits. A schema using one runs on the interpreted engine's collecting walk, which knows where every value is; other schemas are untouched.ata-validator/compatgives every keyword this context andit.schemaPath, which is what express-openapi-validator'sreadOnlyandwriteOnlykeywords use to strip read-only properties from a request.ata-validator/compattakes what a library sets up on it in its own code: the formats plugin'saddFormats()is accepted and registers its formats as the reference does, a keyword registered under a list of names is registered under each, and a draft-04 schema (OpenAPI 3.0's meta-schema) is read as draft-07 with itsidas$idand checked against draft-07's meta-schema after its boolean bounds are normalized. A keyword defined only as generated code is now refused when a schema uses it rather than when it is registered, so a library that registers such a keyword for an option it may not use can take the class. With these, express-openapi-validator runs oncompatwith one change of its own (a portablex-eov-type) and validates requests against GitHub's and Stripe's API descriptions, which the default validator cannot load.ata-validator/compatdoes what the reference does while it validates, for the schemas that need it.coerceTypesconverts a value where eachtypeis checked, so it reaches values behindanyOf,oneOf,allOfand$ref;useDefaultsfills defaults behindallOfand$ref; thediscriminatoroption applies theoneOfbranch the tag names; a schema registered under a fragment-only key such as#/components/schemas/Petis found by a$refof that string; a format registered withtype: 'number'is checked on numbers; and custom keywords takebeforeandpostto order keywords that change the data. Errors name the schema a$refpoints to, andoneOfandanyOfwith no passing branch use the reference's wording. express-openapi-validator's own suite, run with ata injected through a small factory option, passes 470 of 470, as it does on the default validator; it passed 429 before these. On 2400 generated documents with composition andcoerceTypes: true, verdicts and coerced data match the reference in every case. A schema keeps its compat copy across compiles, so the work done once per shared schema is not done again: with express-openapi-validator on Stripe's API description, the first request on a second route went from 280 to 200 ms, and the first request overall stays at 1.1 s on both Stripe's and GitHub's.
OpenAPI validators can run on
ata-validator/compat. express-openapi-validator's own test suite passes 470 of 470 with ata injected, as it does on the default validator, and loads GitHub's and Stripe's API descriptions, which the default validator cannot load. Custom keywords can read where their value sits, and compat now coerces, fills defaults and readsdiscriminatorwhere the reference does. Two engine disagreements are fixed.Measured on an Apple M4 Pro, Node 25.2.1, against 1.52.0 with both sides in worktrees, interleaved: cold require, compile and first validate 8.31 to 8.18 ms, Fastify's ten routes to first validated request 2.83 to 2.92 ms, warm compile 60.7 to 63.0 µs a schema, request cost within 3%, hot validation the same (that run moves between 19.5 and 24.5 ns a document on both versions). The browser bundle grows from 125.6 to 128.5 KB gzipped: the interpreted engine carries what compat needs from it (coercion where each type is checked, the discriminator, number formats,
postkeywords). Standalone modules are byte for byte the same size. On SchemaStore's 31,822 sample documents the engines give the same answers.Added
Custom keywords can ask for their data's context with
dataContext: true: the per-value function is also given{ instancePath, parentData, parentDataProperty, rootData }, as the default validator's keywords are, so it can read or remove the value where it sits. A schema using one runs on the interpreted engine's collecting walk, which knows where every value is; other schemas are untouched.ata-validator/compatgives every keyword this context andit.schemaPath, which is what express-openapi-validator'sreadOnlyandwriteOnlykeywords use to strip read-only properties from a request.ata-validator/compattakes what a library sets up on it in its own code: the formats plugin'saddFormats()is accepted and registers its formats as the reference does, a keyword registered under a list of names is registered under each, and a draft-04 schema (OpenAPI 3.0's meta-schema) is read as draft-07 with itsidas$idand checked against draft-07's meta-schema after its boolean bounds are normalized. A keyword defined only as generated code is now refused when a schema uses it rather than when it is registered, so a library that registers such a keyword for an option it may not use can take the class. With these, express-openapi-validator runs oncompatwith one change of its own (a portablex-eov-type) and validates requests against GitHub's and Stripe's API descriptions, which the default validator cannot load.ata-validator/compatdoes what the reference does while it validates, for the schemas that need it.coerceTypesconverts a value where eachtypeis checked, so it reaches values behindanyOf,oneOf,allOfand$ref;useDefaultsfills defaults behindallOfand$ref; thediscriminatoroption applies theoneOfbranch the tag names; a schema registered under a fragment-only key such as#/components/schemas/Petis found by a$refof that string; a format registered withtype: 'number'is checked on numbers; and custom keywords takebeforeandpostto order keywords that change the data. Errors name the schema a$refpoints to, andoneOfandanyOfwith no passing branch use the reference's wording. express-openapi-validator's own suite, run with ata injected through a small factory option, passes 470 of 470, as it does on the default validator; it passed 429 before these. On 2400 generated documents with composition andcoerceTypes: true, verdicts and coerced data match the reference in every case. A schema keeps its compat copy across compiles, so the work done once per shared schema is not done again: with express-openapi-validator on Stripe's API description, the first request on a second route went from 280 to 200 ms, and the first request overall stays at 1.1 s on both Stripe's and GitHub's.
Fixed
A declared property whose value is
undefinedgave different answers on different engines: the generated code skipped it, as the default validator does, and the interpreted engine and the closures used where code generation is blocked checked it and rejected. All of them now skip it. No JSON document carries such a value, but an object about to be serialized often does ({ id: req.params.id }with no such parameter).tests/test_undefined_property.jsholds the engines together.OpenAPI's
nullable: truewas not applied in a schema reached only through a$refinto a place that is not a subschema keyword, such as an OpenAPIcomponentssection, sonullwas rejected there. The normalization now follows local references.ata-validator/compatreads a rejection about 37% faster and compiles its first schemas about 30% faster. Its errors are reshaped into the reference's own, so the validator underneath no longer builds the rich fields that are then dropped, and each schema path is split and classified once rather than on every rejection. The meta-schema that checks every compiled schema runs on the interpreter, which has nothing to compile: ten route schemas throughexpress-json-validator-middlewarewent from 7.7 to 5.5 ms to compile and answer once, and a rejected body with every error collected from 4.1 to 2.6 µs (the default validator takes 1.9).tests/test_ajv_parity.jspasses unchanged.A schema that needs normalizing (OpenAPI's
nullable, draft-04 booleanexclusiveMinimum, draft-07 and 2019-09 spellings) compiles about twice as fast when it is large. The copy is built as ordinary objects rather than prototype-less ones reattached at the end, which V8 kept in its slow dictionary mode, and the check for "did normalization change anything" compares the copy in place instead of serializing the whole document twice. GitHub's 11 MB of OpenAPI components went from 728 to 320 ms to construct five times; openapi-backend loading Stripe's API description throughcustomizeAjvwithata-validator/compatwent from 14.6 to 6.6 s. The default validator cannot load either document: it stops at"nullable" cannot be used without "type", and with those removed it ran ten minutes on Stripe's and stopped when the process ran out of memory at 3.3 GB.A normalized schema is kept per schema object, so a registry of hundreds of schemas is not normalized again for every validator built over it. express-openapi-validator registers each of an OpenAPI document's components; its first request on GitHub's API description went from 10.5 to 1.1 s, and on Stripe's from 3.7 to 1.1 s. A validator built with
assertFormat: falsestripsformatfrom a copy of its own, so the shared schema keeps its checks for every other validator;tests/test_normalized_cache.jsholds that, and the whole test suite and the SchemaStore differential were run once with the shared copies frozen, with no write to one.
1.52.0
11 Oct 2026A
oneOfwhose branches each allow only their own properties ({ properties: {...}, additionalProperties: false }, no property in two branches) is answered by the branch the object's first key names instead of trying every branch. Traefik's file-provider schemas, 25 middleware branches, go from 10.8 to 1.2 µs a document. Values that are not objects, and objects with no keys, still try every branch.A small object definition referenced from many places in a large schema is written as several functions with the same body, each serving at most four references. One function called with objects of dozens of shapes falls off V8's fast property reads; SchemaStore's detekt sends 72 shapes through one rule definition and goes from 6.3 to 1.1 µs a document. Definitions without properties are not copied.
The linear-time pattern engine, which runs the patterns the platform RegExp cannot be trusted with, steps through one flat transition table of 16-bit entries instead of a state object per character: 25 to 35% less per test on SchemaStore's patterns, and less memory than before (17.9 against 21.2 KB per pattern in use). dart-build goes from 2.82 to 1.96 µs a document, popxf from 6.44 to 4.46, apple-app-site-association from 0.73 to 0.50.
Faster where ata was slowest, smaller server bundles, and both engines answering alike on objects that inherit keys. Three changes to the generated code take on the SchemaStore schemas where ata lost to the default validator, a server bundle leaves out the tools an application does not import, and the generated code now reads only an object's own keys, as the interpreter does.
Measured on an Apple M4 Pro, Node 25.2.1. On SchemaStore's 446 schemas with sample documents, ata is slower than the default validator on 42 rather than 52, and the median ratio moves from 1.68x to 1.72x; among schemas of 50 to 200 KB it is slower on one rather than five. Against 1.51.0: require and first check within noise (4.95 to 5.06 ms and 4.90 to 4.87 in the two orders), a Fastify app's first validated request the same, hot paths the same with code generation and 6 to 9% faster without it, heap per validator the same. The browser bundle is 1.0 KB larger and the edge bundle 0.1 KB. Standalone modules grow where a schema walks object keys: across SchemaStore's 875, the gzipped total grows 4.0%, the median changed module 3.2%, and the ten-field user schema in the README not at all.
Changed
A
oneOfwhose branches each allow only their own properties ({ properties: {...}, additionalProperties: false }, no property in two branches) is answered by the branch the object's first key names instead of trying every branch. Traefik's file-provider schemas, 25 middleware branches, go from 10.8 to 1.2 µs a document. Values that are not objects, and objects with no keys, still try every branch.A small object definition referenced from many places in a large schema is written as several functions with the same body, each serving at most four references. One function called with objects of dozens of shapes falls off V8's fast property reads; SchemaStore's detekt sends 72 shapes through one rule definition and goes from 6.3 to 1.1 µs a document. Definitions without properties are not copied.
The linear-time pattern engine, which runs the patterns the platform RegExp cannot be trusted with, steps through one flat transition table of 16-bit entries instead of a state object per character: 25 to 35% less per test on SchemaStore's patterns, and less memory than before (17.9 against 21.2 KB per pattern in use). dart-build goes from 2.82 to 1.96 µs a document, popxf from 6.44 to 4.46, apple-app-site-association from 0.73 to 0.50.
A server bundle built with esbuild, Rollup or webpack reads a new ESM entry,
index.bundle.mjs, through themodulecondition, which those bundlers match and Node does not. It imports each tool on its own, as the browser entry does, so the tools an application does not import stay out: an app that imports onlyValidatorgoes from 142.8 to 135.0 KB gzipped with esbuild, 150.0 to 141.5 with Rollup and 134.9 to 127.1 with webpack. Node and Bun at run time keep the entries they had, since loading every tool up front cost 7.6 ms in Node before 22.10 and 1.3 ms in Bun.bun builddoes not matchmoduleunless asked:bun build --conditions=modulegives 134.7 KB against 142.6.
Fixed
The generated code walked an object's keys with
for...in, which also visits enumerable keys its prototype carries, where the interpreter reads own keys only. A class instance or an object made withObject.createwas rejected whereadditionalPropertiesis false, and undernotthat turned into accepting what the interpreter rejects: on the official suite's object data rebuilt over such prototypes, 236 verdicts and 102 error lists differed, two of them accepts. The key loops of both generators now skip keys an object does not own, with the prototype read once per loop; an ordinary object, whose prototype is Object.prototype, takes its keys as before, soJSON.parseoutput pays nothing.tests/test_inherited_keys.jsholds the 2,918 variants. A pollutedObject.prototypeis not covered: the generated code takes an ordinary object's prototype as clean, as the default validator does.The shared runtime (
ata-errors.mjs, schema modules,errorRuntimeSource) called its bundled files through a local function namedrequire. A tool that reads the file without scope tookrequire('./interpreter-core')for an import of a file that is not there: knip reported ten unresolved imports in a project that checks its generated sources. The local function has another name now.
1.51.0
11 Oct 2026ata buildbuilds schemas that reference each other by$id. A schema whose references leave it gets the other inputs registered beside it, as theschemasoption would; one that resolves within itself is built as before. Such schemas used to be skipped as not AOT-compatible: on 64 schemas that reference each other 1124 times, 2 were built, now 63 are (the 64th holds only$defs).With
--mode schemathose modules name their schema by$id, and the schemas are written once toata-registry.mjsbeside the runtime. For the 19 of those schemas one application builds browser validators for, that is 63.6 KB gzipped, minified, against 273.6 KB for self-contained modules, which each carry what they reference. The answers are the same: on 59,724 document and schema pairs from the project's fixtures, verdicts match the self-contained modules and another validator, and error lists match the runtimeValidator.toSchemaModule(validator, { runtime, registry })andregistrySource(schemas)inata-validator/aotdo it by hand.ata build --base-uri <uri>names every input without an$idby the URI and its file name, so schemas that reference a shared file by URL or relative path build. Kubernetes schemas reference one_definitions.jsonthat declares no$id: none of them built before, now each does.
Schemas that reference each other now build. A schema whose
$refleads into another input used to be skipped byata build; it is now built against the others, and with--mode schemathe schemas are written once to a shared registry that keeps only the definitions the modules reach.--base-urinames shared files that declare no$id, such as the definitions file Kubernetes schemas reference.Measured on an Apple M4 Pro, Node 25.2.1, minified with esbuild, gzipped: eight Kubernetes kinds go from not built to 32.6 KB as schema modules (68.6 KB self-contained), and the 19 browser validators of a project with 64 schemas that reference each other 1124 times come to 63.6 KB (273.6 KB self-contained). Verdicts match another validator on 15,348 Kubernetes and 59,724 project document pairs, and error lists match the runtime
Validator. Against 1.50.0: require, first check, hot paths and a Fastify app's first request are within noise; the browser bundle is 0.48 KB larger; reading the errors of a validator with theschemasoption costs 2 to 3% more, since those errors are now put in order rather than left as the engine emitted them.Added
ata buildbuilds schemas that reference each other by$id. A schema whose references leave it gets the other inputs registered beside it, as theschemasoption would; one that resolves within itself is built as before. Such schemas used to be skipped as not AOT-compatible: on 64 schemas that reference each other 1124 times, 2 were built, now 63 are (the 64th holds only$defs).With
--mode schemathose modules name their schema by$id, and the schemas are written once toata-registry.mjsbeside the runtime. For the 19 of those schemas one application builds browser validators for, that is 63.6 KB gzipped, minified, against 273.6 KB for self-contained modules, which each carry what they reference. The answers are the same: on 59,724 document and schema pairs from the project's fixtures, verdicts match the self-contained modules and another validator, and error lists match the runtimeValidator.toSchemaModule(validator, { runtime, registry })andregistrySource(schemas)inata-validator/aotdo it by hand.ata build --base-uri <uri>names every input without an$idby the URI and its file name, so schemas that reference a shared file by URL or relative path build. Kubernetes schemas reference one_definitions.jsonthat declares no$id: none of them built before, now each does.The schema registry keeps only the definitions its modules reach, after checking that every reference still resolves without the rest. For eight Kubernetes kinds it is 68.5 KB rather than 242.3 KB, and the eight modules with the registry and the runtime come to 32.6 KB gzipped, minified, against 68.6 KB for self-contained modules.
A schema skipped for a reference that does not resolve says which reference, and what would resolve it, instead of "not AOT-compatible".
Fixed
Errors behind a
$refinto another document (theschemasoption,addSchema) are ordered as they would be inline. The ordering followed only references within the root, so the generated code and the interpreter each kept their own order and disagreed:requiredbeforeadditionalPropertiesfrom one, after the nested property errors from the other.ata buildprints its warnings. A module whose error detail the generator declined reported every failure as the single ATA9000 error and the build said nothing; it now says so for each such module and points to--mode schema.
1.50.0
10 Oct 2026ata-validatorleaves its code generator out where generated code cannot run. Cloudflare Workers and Vercel Edge refusenew Function, so the generator could only fail there and fall back; a bundler building for them sets theworkerdoredge-lightexport condition, and the package now resolves to an entry without it (index.edge.js). Nothing changes in the code that imports it: the same names, and with code generation blocked the same verdicts and errors as the full package, 2230 cases of the 2020-12 and draft-07 suites compared. A Worker with one validated route, bundled by wrangler 4.149.0, went from 912.8 KiB (179.1 KiB gzipped) to 376.4 KiB (70.4 KiB gzipped), and answered the same requests underwrangler dev. Modules fromata buildload through it;Validator.bundleand the other emitters point toata-validator/aot, as in a browser bundle, where they already did.ata-validator/litestays for pages with a Content-Security-Policy that blocksunsafe-evaland for browser extensions, which no condition identifies. The package's index.js is split for it: the generator's paths are inlib/codegen-engine.jsand the public names inlib/public-api.js; requiring the package takes the same time (4.52 against 4.54 ms, fresh processes).Compact modules for production builds.
ata build --no-source(or withNODE_ENV=production) can write each module as its verdict function and its schema without the descriptive keywords, and take error detail from one file the build writes beside the modules,ata-errors.mjs, which every module of the build imports. The runtime walks the schema with the interpreted engine when a document fails, so it only runs on rejections. The build chooses by size, per module, and writes the runtime only when the modules that use it save more than it weighs; one small schema keeps its self-contained module, and--errors inline|sharedoverrides the choice. On seven SchemaStore schemas (abc-supply-plan 13, airlock-microgateway 3, aspire 8, babelrc, github-workflow, prettierrc, tsconfig) imported into one app and minified, the validation code goes from 148.9 to 63.3 KB gzipped and from 35.8 to 13.8 ms to import with code generation blocked (Apple M4 Pro, Node 25.2.1). Forty SchemaStore schemas in one app go from 513.8 to 211.8 KB gzipped (5792.5 to 1566.3 KB minified) and from 61.7 ms to 32.0 ms to import. The runtime carries only what the build's schemas use: format checks, the linear-time pattern engine and deep equality each stay out when no schema has the keyword that reaches them, so the runtime is 11.3 KB gzipped for schemas with none of them and 18.6 KB with all three; 336 of the 445 SchemaStore schemas with sample documents run on a runtime with at least one left out. A compact module also takes the pattern engine from the runtime instead of embedding its own copy, 2.9 KB gzipped, which 147 of 875 SchemaStore modules carried each. On Microsoft's Accessibility Insights assessment schema, the module and its runtime together are 33.6 KB gzipped against 75.9 for the self-contained module. The cost is on rejections: the errors come from an interpreted walk of the document, 398 ns for a rejected request body against a four-field user schema where the self-contained module takes 52, and 57 µs for a 22 KB assessment file with one wrong field where it takes 21 (with several wrong fields, 63 against 94). A build that cares more about rejection speed than size passes--errors inline.toCompactModule,compactDeclineanderrorRuntimeSourceinata-validator/aotdo the same by hand.ata build --mode schema: schema modules, for pages where the bytes shipped matter more than the time per check. A module is its schema without the descriptive keywords and a few lines, and the verdict comes from the shared runtime too, the interpreted engine with its closure tree for a schema used often. Forty SchemaStore schemas in one app, minified, go to 101.6 KB gzipped (212.6 as compact modules, 513.8 self-contained), and loading them and checking each once takes 23.0 ms against 35.5 for compact modules. Once warm a check costs about five times what generated code costs, 3289 against 590 ns a document on SchemaStore samples. The answers are the runtime Validator's: no verdict differs on the official suites in five dialects or on 13741 SchemaStore documents, and every error list of the 11362 rejected ones is the runtime's.toSchemaModuleinata-validator/aotdoes it by hand.
Less to download and less to load. A production build can leave error detail out of its modules and share one error runtime among them, or ship each module as its schema alone; a browser bundle keeps only the tools it imports; and in Cloudflare Workers and Vercel Edge the package leaves out the code generator those runtimes cannot run. The answers do not change: every new path is held to the runtime Validator's verdicts and error lists on the official suites in five dialects and on SchemaStore's sample documents.
Measured on an Apple M4 Pro, Node 25.2.1: forty SchemaStore schemas in one app, minified with esbuild, go from 513.8 KB gzipped to 211.8 KB with compact modules and 101.6 KB with schema modules, and from 61.7 to 32.0 ms to import with code generation blocked. An app that imports only
Validatorbundles to 123.5 KB gzipped for the browser, against 130.9 with 1.49.0; a Worker with one validated route goes from 179.1 to 70.4 KiB gzipped under wrangler. Against 1.49.0, from separate worktrees: a first validated request of a Fastify-shaped app comes 2.82 ms after setup against 2.99, validation on warm paths and the modulesata buildwrites by default are unchanged, and requiring the package takes 4.61 ms against 4.51, one more file to load (the code generator's paths, now separate so the edge entry can leave them out).Added
ata-validatorleaves its code generator out where generated code cannot run. Cloudflare Workers and Vercel Edge refusenew Function, so the generator could only fail there and fall back; a bundler building for them sets theworkerdoredge-lightexport condition, and the package now resolves to an entry without it (index.edge.js). Nothing changes in the code that imports it: the same names, and with code generation blocked the same verdicts and errors as the full package, 2230 cases of the 2020-12 and draft-07 suites compared. A Worker with one validated route, bundled by wrangler 4.149.0, went from 912.8 KiB (179.1 KiB gzipped) to 376.4 KiB (70.4 KiB gzipped), and answered the same requests underwrangler dev. Modules fromata buildload through it;Validator.bundleand the other emitters point toata-validator/aot, as in a browser bundle, where they already did.ata-validator/litestays for pages with a Content-Security-Policy that blocksunsafe-evaland for browser extensions, which no condition identifies. The package's index.js is split for it: the generator's paths are inlib/codegen-engine.jsand the public names inlib/public-api.js; requiring the package takes the same time (4.52 against 4.54 ms, fresh processes).Compact modules for production builds.
ata build --no-source(or withNODE_ENV=production) can write each module as its verdict function and its schema without the descriptive keywords, and take error detail from one file the build writes beside the modules,ata-errors.mjs, which every module of the build imports. The runtime walks the schema with the interpreted engine when a document fails, so it only runs on rejections. The build chooses by size, per module, and writes the runtime only when the modules that use it save more than it weighs; one small schema keeps its self-contained module, and--errors inline|sharedoverrides the choice. On seven SchemaStore schemas (abc-supply-plan 13, airlock-microgateway 3, aspire 8, babelrc, github-workflow, prettierrc, tsconfig) imported into one app and minified, the validation code goes from 148.9 to 63.3 KB gzipped and from 35.8 to 13.8 ms to import with code generation blocked (Apple M4 Pro, Node 25.2.1). Forty SchemaStore schemas in one app go from 513.8 to 211.8 KB gzipped (5792.5 to 1566.3 KB minified) and from 61.7 ms to 32.0 ms to import. The runtime carries only what the build's schemas use: format checks, the linear-time pattern engine and deep equality each stay out when no schema has the keyword that reaches them, so the runtime is 11.3 KB gzipped for schemas with none of them and 18.6 KB with all three; 336 of the 445 SchemaStore schemas with sample documents run on a runtime with at least one left out. A compact module also takes the pattern engine from the runtime instead of embedding its own copy, 2.9 KB gzipped, which 147 of 875 SchemaStore modules carried each. On Microsoft's Accessibility Insights assessment schema, the module and its runtime together are 33.6 KB gzipped against 75.9 for the self-contained module. The cost is on rejections: the errors come from an interpreted walk of the document, 398 ns for a rejected request body against a four-field user schema where the self-contained module takes 52, and 57 µs for a 22 KB assessment file with one wrong field where it takes 21 (with several wrong fields, 63 against 94). A build that cares more about rejection speed than size passes--errors inline.toCompactModule,compactDeclineanderrorRuntimeSourceinata-validator/aotdo the same by hand.ata build --mode schema: schema modules, for pages where the bytes shipped matter more than the time per check. A module is its schema without the descriptive keywords and a few lines, and the verdict comes from the shared runtime too, the interpreted engine with its closure tree for a schema used often. Forty SchemaStore schemas in one app, minified, go to 101.6 KB gzipped (212.6 as compact modules, 513.8 self-contained), and loading them and checking each once takes 23.0 ms against 35.5 for compact modules. Once warm a check costs about five times what generated code costs, 3289 against 590 ns a document on SchemaStore samples. The answers are the runtime Validator's: no verdict differs on the official suites in five dialects or on 13741 SchemaStore documents, and every error list of the 11362 rejected ones is the runtime's.toSchemaModuleinata-validator/aotdoes it by hand.ata-validator/error-runtime, the same error runtime as a package entry, for bundler plugins:compiledModuleFor(schema, { errorRuntime: 'ata-validator/error-runtime' })returns a compact module that imports it, and the bundler keeps one copy, sharing its error-code table and schema ordering withata-validator/compiled. BehindfromCompiled, a compact module answers asnew Validator(schema)does, rich errors and defaults included: checked on 563 schemas and 1994 cases of the 2020-12 and draft-07 suites. A schema with a$refthat leaves it (a meta-schema the runtime resolves from its own copies, a pointer to nothing) keeps the self-contained module, since the runtime holds the embedded schema and nothing else.
Changed
A browser bundle carries only the tools it imports. The ESM entries bundlers read (index.browser.mjs, index.edge.mjs) import TypeScript generation, the renderers, the output format, the retry message and the schema description one by one, where index.js loads them on first call through
require, which a bundler follows but cannot drop. An application that imports onlyValidator, bundled for the browser with esbuild, went from 130.9 KB gzipped (1.49.0) to 123.5 KB. Node keeps loading them on first call.The interpreted engine (
ata-validator/lite, and the default entry wherever code generation is blocked) compiles its closure tree once a schema has been used 1000 times, and answers the calls before that with its plain walk. Building the tree for a whole schema cost as much as 3000 to 30000 plain walks on SchemaStore schemas, so a page or a short-lived worker paid for it on its first check and never got it back; the compiler's own source is now loaded only when it is needed. In a minified bundle with code generation blocked, the first check of a SchemaStore schema of 3.3 KB went from 2.94 to 2.20 ms, 10.8 KB from 5.15 to 4.24, 33 KB from 6.34 to 5.27, 97 KB from 10.83 to 6.39, and tsconfig's 469 KB from 13.86 to 11.99; importing is unchanged, and so is the cost of a check once the tree is built (432 against 447 ns, 342 against 336, 650 against 653). A server that validates thousands of documents pays at most the walk's extra cost over its first 1000 calls, which on tsconfig is 0.85 ms, once. Two things the plain walk did differently from the tree came out once it answered real calls, and both are fixed: it checked custom keywords only on leaf nodes, so a keyword besidepropertieswas skipped (the tree answered every call before, and compiled every schema of the official suite, so this was not reachable in a release), and data nested about 700 levels deep overflowed its stack where the tree's did not; such data now goes to the tree.tests/test_interpreter_tiering.jsholds the walk to the tree on the suite's 3365 cases.The interpreted engine recognises leaf plans again. Its index records a base URI for every schema node, and the plan read that as a resource boundary on every node, so no plan was ever a leaf and every value check went through the full evaluation prologue. A plan now carries a base only where a resource starts; references already hand their target's base over. On the engine without its plan compiler (the shared error runtime), a 30-field flat object goes from 939 to 916 ns and a four-field request body from 118 to 113 ns, measured twice each with the same order of results.
A compact module sets nothing up at import: its schema is parsed and the runtime prepared on the first rejected document whose errors are read. Importing three compact modules takes 4.00 ms against 4.67 for the self-contained ones, eight take 5.50 against 7.65. The first error list a page reads costs more instead, once: 4.50 against 2.76 ms for three validators, 6.59 against 4.82 for eight, so import and first error read together are 8.50 against 7.43 ms for three and 12.09 against 12.47 for eight; after that a rejection costs the same (about 0.09 ms on these schemas). What is left of the difference is the runtime's own code being compiled on its first use, about 1.1 ms. In a browser the runtime does that when the page is idle (
requestIdleCallback, once, on a small schema of its own), so the first real error list finds it compiled: with an idle moment between load and the first rejection, a one-property schema's first error read takes 0.61 ms against 0.74 for a self-contained module, and three SchemaStore schemas after it 2.86 against 2.67. In Node it does the same once the event loop is first free after start-up, on a timer that keeps no process alive, so a server has it done before its first request (0.60 against 0.75 ms, and 2.85 against 2.65 for the three schemas after it); a rejection in the same synchronous step as the import, before the loop turns, still pays it. Workers allow no timer at start-up, and there nothing runs ahead. On data nested deeper than its walk can go (thousands of levels), the runtime reports the module's ATA9000 stub instead of throwing; the verdict still comes from the module. Two things keep that first read down: the runtime sorts errors by walking only the paths they name, where the runtime Validator numbers every node of the schema on its first sort, and a build tells the runtime when a schema has no embedded resource or anchor, so it does not index the schema (1.29 to 0.20 ms on the first schema measured).The interpreted engine builds error paths only while it collects errors. A verdict walk concatenated an instance path and a schema path for every child it visited and never read them: on the 22 KB assessment file above, 48.1 to 40.7 µs.
A compact module reports the errors the runtime
Validatorreports withrichErrors: false(and, like the self-contained module, withouterrorMessage), in its order: the order of the keywords in the schema. A self-contained module reports them in the order its collector meets them, which differs (a parent'sadditionalPropertiesbefore the child properties' errors, for instance). Neither order was documented. Checked over the official suites in five dialects and 13741 SchemaStore documents: no verdict differs from the self-contained module, and every error list equals the runtime's. On that corpus the self-contained module differed from the runtime in 23 error lists (the errors inside aoneOfbranch) and leftallOf: [false]without its ATA4005 code; on 73 documents it could only report ATA9000, where the compact module reports the errors.
1.49.0
10 Oct 2026More patterns run on the platform RegExp instead of the linear-time engine. The rule in
lib/regex-linear.jsnow also accepts a pattern that is deterministic around its loops, on the Glushkov construction: the characters that can follow a position inside a loop are pairwise disjoint, a position reaches a follower by one route only (so(a+)+and(\w+\s?)*stay out), no loop body matches the empty string, and the pattern is anchored. That takes in dotted identifiers,^[^/]+/[^/]+$, version numbers and(ab)*. Over SchemaStore's 1277 patterns 90 more go native, with no answer difference on 18000 random inputs and no slow run on 20000-character adversarial probes; the engine is 3.5 to 5x the RegExp per character, so a schema whose checks are mostly patterns moves accordingly (SchemaStore's elm from 289 to 89 ns a document and mimetypes from 176 to 65, against the default validator's 161 and 82).An
enumtest on a property binds the value once instead of reading the property for every member: a 32-member enum on a required property read it 32 times. On an array of 32 items in six shapes, 2694 to 1507 ns; SchemaStore's devinit 4650 to 2454.The linear-time engine steps the last character outside its loop, so the loop selects no table per character; a few percent on long strings, no change in answers.
Draft 2019-09 and draft-06 are read rather than taken for 2020-12, which accepted documents those schemas reject; the generated code gets cheaper on configuration-style schemas (defaults, enums, patterns); and a few error-path and correctness fixes.
Measured against 1.48.0 on an Apple M4 Pro, Node 25.2.1, pure JS: on 20 real API schemas, validating a valid document 10 to 11% faster and a realistic invalid one 8 to 9%; compiled modules 4.4% smaller gzipped; cold start unchanged (4.87 to 4.56 ms
require, inside its noise). On SchemaStore's rustfmt schema, a document that already carries its 82 defaults validates in 52 ns where 1.48.0 took 827.Changed
More patterns run on the platform RegExp instead of the linear-time engine. The rule in
lib/regex-linear.jsnow also accepts a pattern that is deterministic around its loops, on the Glushkov construction: the characters that can follow a position inside a loop are pairwise disjoint, a position reaches a follower by one route only (so(a+)+and(\w+\s?)*stay out), no loop body matches the empty string, and the pattern is anchored. That takes in dotted identifiers,^[^/]+/[^/]+$, version numbers and(ab)*. Over SchemaStore's 1277 patterns 90 more go native, with no answer difference on 18000 random inputs and no slow run on 20000-character adversarial probes; the engine is 3.5 to 5x the RegExp per character, so a schema whose checks are mostly patterns moves accordingly (SchemaStore's elm from 289 to 89 ns a document and mimetypes from 176 to 65, against the default validator's 161 and 82).An
enumtest on a property binds the value once instead of reading the property for every member: a 32-member enum on a required property read it 32 times. On an array of 32 items in six shapes, 2694 to 1507 ns; SchemaStore's devinit 4650 to 2454.The linear-time engine steps the last character outside its loop, so the loop selects no table per character; a few percent on long strings, no change in answers.
The generated defaults pass reads a key before it asks
Object.hasOwnabout it, and asks only when the read gives undefined, for plain objects and keysObject.prototypedoes not have. On a configuration schema with 82 defaults (SchemaStore's rustfmt),validate()on a document that already carried every key cost 827 ns and now costs 52; withuseDefaults: falseit is 12. What is written is unchanged: a new test pins the generated pass to the closure pass on documents with inherited names, null prototypes, explicitundefinedand__proto__keys. Found by timing SchemaStore's 431 sample-bearing schemas against the default validator, where ata lost on the schemas with many defaults and nowhere else for this reason.
Fixed
A draft-06 or Draft 2019-09 schema was read as 2020-12 as written, so its tuple
itemsandadditionalItems, and under draft-06 itsdependenciesand the reading of$refsiblings, were not applied: documents those schemas reject were accepted. On the official suites that was 30 cases of draft-06 and 38 of 2019-09. Both dialects are now read properly (see Added).The generated code rejected valid documents when
unevaluatedPropertiesorunevaluatedItemssat beside a$refback to the document root, as in a tree whose branches say{ unevaluatedProperties: false, $ref: '#' }: what the root evaluates through its own$refwas not counted. Such a schema now goes to the interpreted engine, which answers it. Found through 2019-09's$recursiveReftests; the shape is plain 2020-12 and the 2020-12 suite does not contain it.A standalone module built without
--one-passreported every error with an extra_ofield, the ordinal the runtime sorts errors by, which a module never reads: the collector is now compiled without it for modules and bundles, so an error carries the documented keys only (tests/test_aot_error_shape.js). Present since the ordinal was introduced; the one-pass module and the runtime were not affected.
1.48.0
9 Oct 2026ata-validator/liteis published: theValidatorand the one-shot functions on the interpreted engine alone, without the code generator, for pages and workers where the size of what ships matters. It existed in the repository with its parity test since 1.19.0 and was never in the tarball or theexportsmap, so nothing could import it. In a Hono app bundled for Workers the runtime entry is 136 KB gzipped and the lite entry 58 KB, with the same verdicts and errors.A JSR package,
@ata/validator, fromjsr/: it re-exports the npm package of the same version, sojsr:@ata/validatorandnpm:ata-validatorare one engine. Published by.github/workflows/jsr.ymlafter the npm version is visible;tests/test_version_sync.jsholds its version and specifiers topackage.json.CI runs the package under Deno on every change: a smoke test with dynamic code refused the way Deno Deploy refuses it (
tests/deno_smoke.ts), the suite withevalandnew Functionblocked, and the official suite in three dialects.
Large schemas compile to code V8 can optimize, which makes a 167 KB configuration schema three to seven times faster to validate; the lite entry is published at last; the text scanner stops at the first member it can refuse; the package is run under Deno in CI and published on JSR; and
parseJSONworks with the native engine loaded.Measured against 1.47.0 on an Apple M4 Pro, Node 25.2.1, pure JS: cold start 7.2 to 6.9 ms and
requirealone 4.8 to 4.5 ms; a served request, hot validation, compile time, heap per validator and everyata buildmodule unchanged, the modules byte for byte. A 69 KB request body whose first item is wrong is refused from the text in 26 µs where 1.47.0 took 112.Added
ata-validator/liteis published: theValidatorand the one-shot functions on the interpreted engine alone, without the code generator, for pages and workers where the size of what ships matters. It existed in the repository with its parity test since 1.19.0 and was never in the tarball or theexportsmap, so nothing could import it. In a Hono app bundled for Workers the runtime entry is 136 KB gzipped and the lite entry 58 KB, with the same verdicts and errors.A JSR package,
@ata/validator, fromjsr/: it re-exports the npm package of the same version, sojsr:@ata/validatorandnpm:ata-validatorare one engine. Published by.github/workflows/jsr.ymlafter the npm version is visible;tests/test_version_sync.jsholds its version and specifiers topackage.json.CI runs the package under Deno on every change: a smoke test with dynamic code refused the way Deno Deploy refuses it (
tests/deno_smoke.ts), the suite withevalandnew Functionblocked, and the official suite in three dialects.
Fixed
parseJSONthrewnative.parseJSON is not a functionwhenever the native engine was loaded: the addon has not carried that function since the per-platform packages, and the pure-JS fallback hid it. It parses with JSON.parse, and a Buffer is decoded first (tests/test_parse_json.js).
Changed
validateJSON()resolves the position map on the first read of an error'sdataFrameoranchor, not when the error list is read. The map is a walk of the document that a caller reading messages and paths never needs; on a 60 KB document with 137 errors reading the list went from 907 to 505 µs, and reading every frame costs what it did. The frames are the same shape: present when the text has the value, absent otherwise, enumerable and serialized, and assignable.Large property subschemas compile to functions of their own instead of inline code, in all three generators (the verdict, the error collector and the one-pass function). V8 does not optimize a function past its bytecode budget, and a configuration schema of a few hundred properties compiled to one function of a megabyte or more that ran in the interpreter for its whole life. Measured on a 167 KB schema of 140 sections with a 60 KB document, fresh
JSON.parseon every call: the runtime verdict 522 to 154 µs (the parse alone is 115), a rejected document with its errors read 335 to 71 µs; a standalone module'svalidate()on the rejected document 891 to 146 µs, itsvalidateJSON()with positions 1249 to 494, and with--one-pass613 to 402. The threshold is on the schema's own size, so small schemas compile exactly as before; a property of fewer than 700 bytes of schema stays inline unless the function around it already holds 24 KB of code.The JSON text scanner behind
isValidJSON()andvalidateJSON()ends the scan when a member fails and its key cannot repeat later in the object: two native searches over the rest of the text, for the key as written and for any backslash, which is the only way to spell it differently, replace the full pass the duplicate-key rule required. A 69 KB request body whose first item is wrong is refused in 26 µs where it took 112, against 149 for JSON.parse alone; a wrong top-level shape stays at 1.5 µs. Verdicts are unchanged: a key that does repeat, plain or escaped, still goes to the parser (tests/test_scanner_early_exit.js, and the scanner differential's 413,178 comparisons agree).package.jsondeclares"type": "commonjs", which it already was. Deno reads a.jsfile as ESM without it; Node's default is unchanged.
1.47.0
8 Oct 2026A Hono route on Bun to its first served request through the runtime API, from
benchmark/bundle: 9.1 to 7.6 ms, against 3.5 for the same app with no validation;Cold start (require, compile, first validate): 8.5 to 7.0 ms;
requirealone 6.4 to 4.6 ms, since the error generators are read on the first error compile and the format and regex sources on the first schema that needs them;Heap retained per validator that has validated, two thousand validators of a ten-key schema: 9.5 to 8.3 KB;
Standalone modules can carry the runtime's one-pass error function, the package starts faster and holds less per validator, the generators decline a subschema they produced no code for, and the supply chain is tightened: a tracked lockfile, pinned tools, attested release tarballs, and one place each for dynamic code and environment reads.
Measured against 1.46.1 on an Apple M4 Pro, Node 25.2.1, pure JS:
A Hono route on Bun to its first served request through the runtime API, from
benchmark/bundle: 9.1 to 7.6 ms, against 3.5 for the same app with no validation;Cold start (require, compile, first validate): 8.5 to 7.0 ms;
requirealone 6.4 to 4.6 ms, since the error generators are read on the first error compile and the format and regex sources on the first schema that needs them;Heap retained per validator that has validated, two thousand validators of a ten-key schema: 9.5 to 8.3 KB;
A served request, hot validation, the suite harness and the official suite: unchanged within noise;
Modules from
ata buildwithout--one-pass: byte for byte the same; with it, on a 175 KB schema and a 15 KB document, a rejected document in 39 µs where the older collector took 89.
Costs: the browser runtime bundle 125.2 to 128.0 KB gzipped (the one-pass emitter and the generator check);
ata buildover twenty schemas 94 to 99 ms, since a module's emission compiles the verdict in full where the validator kept only the light form.Fixed
The code generators decline a subschema that constrains something and produced no code for it, instead of reading "no lines" as "holds". Every place that turns a subschema into lines (a nested verdict, a branch verdict, the inline annotation verdicts of run-time
unevaluatedPropertiesandunevaluatedItems, the three entry points) now checks that a schema with a validating keyword produced at least one line; a schema that trips it runs on the interpreted engine. This is the shape of the silent acceptances the generator has produced through$refhandling, and of the simplest form of the 1.46.0 one (with that fix removed, the check alone keeps{ anyOf: [{}, { additionalProperties: { const: true } }] }rejecting). It cannot see a check dropped from a subschema that produced other lines; the differential test below is what catches those.formatand an already-knowntypeare not counted, and unknown keywords are not either. No schema of the official suite, the SchemaStore corpus or the differential tests changed engine.The unevaluated differential test now compares
validate()after enough reads that it is the one-pass function, runs every third schema through a standalone module (with and without the one-pass error path), and generates branches that are empty or carryadditionalPropertiesat their root. With the 1.46.0 fix removed and the generator check disabled, it reports the acceptance.
Changed
Every place the package turns generated source into a function goes through one module,
lib/compile-fn.js, instead of callingnew Functionin seven files, and every environment variable the package honours is read throughlib/env.js, which lists them. Nothing changes in what runs; a reader auditing what the package evaluates, or a supply-chain scanner reporting it, finds one site. Where dynamic code is blocked the fallback to the interpreted engine is as before (tests/test_no_eval.js).A test bundles the Node entry with esbuild and runs the bundle (
tests/test_node_bundle.js): the native engine is found through seven optional packages of which at most one is installed on any machine, so the loader must require the platform package by a computed name, and a literal require of each, tried in this cycle, stopped the bundle of every app that imports the package.Supply chain: the package's own
package-lock.jsonis tracked and CI, the prebuilds and the publish install withnpm ci; the npm the publish job updates itself to is pinned; the Bowtie image is pinned by digest. A new workflow attaches to every GitHub release the packed tarball, a Sigstore build provenance attestation for it and a checksum file, so a tarball taken from a release verifies withgh attestation verify. On the repository, Dependabot alerts and security updates, secret scanning and push protection are on.tests/test_version_sync.jsnow also holds the lockfile to the package version, so a release bump that forgetsnpm install --package-lock-onlyfails the release check.A validator retains 8.3 KB of heap instead of 9.5 once it has validated (two thousand validators of a ten-key schema, each with its own schema, measured with
benchmark/bench_memory.cjs). The compiled verdict function carried, for as long as it lived, the generated source and the factories that build the tiered forms from it, 2.2 of the 3.4 KB it retained; they are now compiled in full only when asked for, at the tier a validator reaches after its first calls or when a build emits it, from the schema again. A validator that gets there pays one more compile, a few hundred microseconds once; one that answers a handful of requests, or only verdicts, never does. Schemas with custom keywords compile in full at once, so a keyword's compile form still runs once per schema.The error generator and the one-pass generator moved out of
lib/js-compiler.jsintolib/js-compiler-errors.js, read on the first call that compiles a validator's errors. They were 45 percent of the generator's source, parsed by every process at require; a verdict-only process never reads them now, and one that reads errors parses them when it does. The two files share the generator's helpers through an internal object; nothing in the package's interface changes.The format checkers' source (
lib/formats-source.js) and the linear regex engine (lib/safe-regex.js) are read on the first schema that needs them, aformator apattern, instead of with the compiler. Requiring the package costs 10 percent less (6.25 to 5.61 ms, pure JS, medians of 25 interleaved cold processes); a first validation against a schema with a format or a pattern pays about 0.3 ms of that back once.Standalone modules can report errors with the runtime's one-pass function:
toStandaloneModule(schema, { onePass: true })orata compile --one-pass. The one-pass function is whatvalidate()runs once a validator's errors have been read, one walk that checks and collects with a verdict per subtree, written into the module with its closure values as declarations. The older collector walked everything and built every path: on a 175 KB SchemaStore schema and a 15 KB document with two errors, the module'svalidate()on the rejected document takes 39 µs with the one-pass function where the older collector took 89 (a report from a config validator measured 2 to 2.7x against the default validator on 84 to 234 KB schemas and guessed at the position pass, which costs 11 of those). Not the default: the one-pass module is 1.3 to 1.7 times the older one, gzipped, since it carries a verdict per subtree and a counting copy per branch. Where the older collector declines a schema (unevaluated*next to a$ref), the one-pass function is taken without asking: 83 more schemas of the official suite get a module with real errors where they got the stub. The module's program is written without the counting copies the runtime uses to score oneOf and anyOf branches (a second copy of each branch's error body); when no branch holds, every branch's error function runs and the closest is picked from their errors, which measured the same on rejected documents and takes 5 to 12 percent off the module. Errors carry the same fields as before (code, keyword, instancePath, schemaPath, params, message, docUrl) and come in schema order, as the runtime's do. A module built with--sourceframes, or from a validator with a custom format, keeps the older collector.
1.46.1
6 Oct 2026Reading the note…
1.46.0
6 Oct 2026validate()on an accepted body: 37 percent less time; the verdict alone 39 percent lessA rejected body with every error read: 47 percent less; the realistic one 52 percent less, its verdict 59 percent less
The official test suite harness, every error read: draft 2020-12 70 to 49 µs per round, draft 7 30 to 29
Reading the errors of a rejected document is what this release is about. Once a validator's errors have been read,
validate()is one function that checks and collects in a single pass, and the slow parts of that pass are rewritten: run-timeunevaluatedPropertiesandunevaluatedItemsas straight-line code, oneOf and anyOf branches scored with counting copies instead of run twice, error order kept beside the list instead of sorted afterwards,$dynamicRefschemas on generated code.Measured against 1.45.0 on an Apple M4 Pro, Node 25.2.1, pure JS. Twenty SchemaStore API schemas, each with an accepted body, a rejected body and a realistic rejected body (one wrong type deep inside); both builds in one process with the rounds alternating, medians of eleven, summed over the twenty:
validate()on an accepted body: 37 percent less time; the verdict alone 39 percent lessA rejected body with every error read: 47 percent less; the realistic one 52 percent less, its verdict 59 percent less
The official test suite harness, every error read: draft 2020-12 70 to 49 µs per round, draft 7 30 to 29
Costs: cold start 8.0 to 8.4 ms; the browser runtime bundle 116.4 to 125.2 KB gzipped. Modules from
ata buildare the same size, except for a schema with$ref: "#", whose module now carries its error function instead of a stub. A served request's cost is unchanged within three percent.Also in this release:
ata migrate [dir]reports what switching a project from ajv toata-validator/compatwould change, and--writeapplies the mechanical part.A standalone module for a schema with
$ref: "#"anywhere in it reports the real errors; it reported one stub error before.A pattern with a finite repeat of a group stays on the platform regex engine.
A rejected
parse()works out its errors when they are first read.The one-pass function collects from its first check and does not ask the whole-document verdict first; on three of the twenty schemas an accepted body costs 60 to 70 ns more than on 1.45.0 while their rejections cost a third to a tenth. The CHANGELOG has the details.
The full list is in CHANGELOG.md.
1.45.0
5 Oct 2026The interpreted engine is faster. It answers where code generation is blocked (a Content Security Policy without
unsafe-eval, some edge runtimes), it is the engine ofata-validator/lite, and it answers the first calls of a large schema. Measured against 1.44.0 on SchemaStore schemas and their sample documents:Where it comes from:
uniqueItemsover arrays of objects is faster in generated code too: each item is hashed instead of turned into a canonical string, in one implementation both engines now run. A verdict on SchemaStore's SARIF samples takes 57.5 µs where it took 71.8.Reading the errors of a rejected document is a little faster: the error list is created with its first error instead of grown from an empty array, and a schema that reaches its root again through a definition used once (a tree whose nodes hold trees) now gets the one-pass function that validates and collects together. On the official test suite, validating every case and reading every error, a run takes 1.05 times less time on Draft 2020-12 with the default errors and 1.05 times less on draft 7, and 1.05 and 1.11 times less with
richErrors: false.
Changed
The interpreted engine is faster. It answers where code generation is blocked (a Content Security Policy without
unsafe-eval, some edge runtimes), it is the engine ofata-validator/lite, and it answers the first calls of a large schema. Measured against 1.44.0 on SchemaStore schemas and their sample documents:with code generation blocked, validating and reading the errors takes 4.7 µs per tsconfig.json sample where it took 8.5, and 427 µs per SARIF log where it took 568; the other schemas measured are unchanged;
on the valid samples of five popular schemas, a verdict takes 9.9 µs where it took 16.2, and a validation that collects errors 16.0 µs where it took 23.4.
Where it comes from:
enummembership is a Set lookup for primitive values instead of a deep comparison with every allowed value;uniqueItemskeys items by value, or by a structural hash for objects, instead of comparing every pair;the
uriformat checks its characters through a table;while collecting errors, a child schema with no subschemas of its own is asked for its verdict first, and the paths of its errors are built only when it fails.
uniqueItemsover arrays of objects is faster in generated code too: each item is hashed instead of turned into a canonical string, in one implementation both engines now run. A verdict on SchemaStore's SARIF samples takes 57.5 µs where it took 71.8.Reading the errors of a rejected document is a little faster: the error list is created with its first error instead of grown from an empty array, and a schema that reaches its root again through a definition used once (a tree whose nodes hold trees) now gets the one-pass function that validates and collects together. On the official test suite, validating every case and reading every error, a run takes 1.05 times less time on Draft 2020-12 with the default errors and 1.05 times less on draft 7, and 1.05 and 1.11 times less with
richErrors: false.The interpreted engine compares values for
enum,constanduniqueItemsby own properties, as generated code does. The two differed only for objects with inherited enumerable properties, which JSON never produces.Cost: The browser runtime bundle is 116.4 KB gzipped, up from 115.8 KB. Startup and accepting documents are unchanged within noise. Modules from
ata buildare byte for byte the same size.
1.44.0
5 Oct 2026A schema that names something
"__proto__"could accept documents it rejects. Several places copied a schema, or a map keyed by property names, by assignment, and assigning the key"__proto__"sets the copy's prototype instead of creating the property, so the constraint on it was gone from the copy. It happened for a property named"__proto__"next to a$refthat is resolved at compile time (one relative to a nested$id, for example), for a$refpointing at such a property, and for draft-07dependencieson such a property, which affected every engine. For example{"$id": "http://e.x/root", "$defs": {"S": {"$id": "s", "type": "string"}}, "properties": {"__proto__": {"type": "number"}, "r": {"$ref": "s"}}}accepted{"__proto__": "s"}. A schema that lists"__proto__": falseto keep that key out of documents was affected the same way. Affected: measured on 1.40.1, 1.41.0 and 1.43.0; earlier versions were not checked. Every copy now writes such a key as an own property.tests/test_proto_name_rename_invariance.jschecks that a schema and a document using the name answer exactly as the same pair with the name renamed, on every engine and every read, ahead-of-time modules included: 3328 documents, none different.validateJSON()withrichErrors: falsereturned the errors of a rejected document in the order generated code found them, wherevalidate()returns them in schema order, so one document could list its errors in two orders. Both now give the same list. Reading those errors costs about 50 ns more per rejection on a small schema.Faster from process start to the first answer. Measured on 20 SchemaStore schemas as API routes and on the SARIF schema as a command line tool, each run in a fresh process, median of 11, against 1.43.0 (milliseconds, an empty Node process included):
1.43.0 1.44.0 20 routes, first valid requests 47.9 40.7 20 routes, first errors read 67.7 60.2 SARIF, one rejection with its errors read 90.9 33.4 5 MB SARIF log through validateJSON(), valid89.1 57.5 5 MB SARIF log through validateJSON(), invalid, errors read135.8 82.4 Where it comes from:
Fixed
A schema that names something
"__proto__"could accept documents it rejects. Several places copied a schema, or a map keyed by property names, by assignment, and assigning the key"__proto__"sets the copy's prototype instead of creating the property, so the constraint on it was gone from the copy. It happened for a property named"__proto__"next to a$refthat is resolved at compile time (one relative to a nested$id, for example), for a$refpointing at such a property, and for draft-07dependencieson such a property, which affected every engine. For example{"$id": "http://e.x/root", "$defs": {"S": {"$id": "s", "type": "string"}}, "properties": {"__proto__": {"type": "number"}, "r": {"$ref": "s"}}}accepted{"__proto__": "s"}. A schema that lists"__proto__": falseto keep that key out of documents was affected the same way. Affected: measured on 1.40.1, 1.41.0 and 1.43.0; earlier versions were not checked. Every copy now writes such a key as an own property.tests/test_proto_name_rename_invariance.jschecks that a schema and a document using the name answer exactly as the same pair with the name renamed, on every engine and every read, ahead-of-time modules included: 3328 documents, none different.validateJSON()withrichErrors: falsereturned the errors of a rejected document in the order generated code found them, wherevalidate()returns them in schema order, so one document could list its errors in two orders. Both now give the same list. Reading those errors costs about 50 ns more per rejection on a small schema.
Changed
Faster from process start to the first answer. Measured on 20 SchemaStore schemas as API routes and on the SARIF schema as a command line tool, each run in a fresh process, median of 11, against 1.43.0 (milliseconds, an empty Node process included):
1.43.0 1.44.0 20 routes, first valid requests 47.9 40.7 20 routes, first errors read 67.7 60.2 SARIF, one rejection with its errors read 90.9 33.4 5 MB SARIF log through validateJSON(), valid89.1 57.5 5 MB SARIF log through validateJSON(), invalid, errors read135.8 82.4 Where it comes from:
The first 64 calls of a large schema (over about 16K characters with its references followed) are answered by the interpreted engine, which the test suite holds to the same answers and errors, and the schema is compiled if the validator is still in use after that. A short-lived process never pays to generate and compile the code for it.
A schema that would expand past about 400K characters gets its verdict function with shared definitions from the start, instead of expanded first and thrown away.
The vendored meta-schemas load only for a schema that references one, not for every schema that names its dialect in
$schema, which made each compile's cache key a 30 KB string.The errorMessage module loads only for a schema that uses the keyword.
validateJSON()parses the text once when its errors are read, and on a large ASCII document finds the positions of the failing values through the native addon (simdjson), 3.1 ms instead of 8.3 on 5 MB. Without the addon, or with an older one, it works as before.
More schemas run on generated code:
propertyNameswith any subschema (each key is checked as a value), a property named$ref, and properties named afterObject.prototypemembers such asconstructorand__proto__. Of the 980 SchemaStore schemas, 869 run on generated code where 840 did.Cost: The browser runtime bundle is 115.8 KB gzipped, up from 114.1 KB. Requiring the package, compiling a schema and validating once takes the same time within noise. Modules from
ata buildare byte for byte the same size.
1.43.0
5 Oct 2026validateJSON()andisValidJSON()could abort the whole Node process on large schemas with many references to the same definitions, such as SchemaStore's traefik and detekt. The text scanner generated for such a schema came to 0.5 to 1 MB of source in one function, and V8's Maglev compiler aborted the process (raw_hash_map<>::at) while optimizing it, after about a hundred calls, in plain JavaScript on Node 24 and 25. It was intermittent, about half of the runs on those schemas. Affected: 1.30.0 to 1.42.0, measured on the published versions. The scanner is now emitted as one function where it fits in 64 KB and otherwise split into functions of bounded size, with large definitions and members called rather than repeated; the largest scanner function on SchemaStore went from 1 MB to 64 KB. Every one of the 980 SchemaStore schemas was run in its own process, with and without the native addon, with no failure. On traefik-v2 the split scanner is 1.47 times slower than parsing the text would be; on the six other schemas that split it is 1.0 to 2.3 times faster.The compile cache and the preprocess cache were unbounded. They kept every schema's text and generated functions for the life of the process: after compiling each of the 975 SchemaStore schemas once and dropping the validators, 1247 MB stayed on the heap. Both caches now keep at most 128 entries, oldest out first, and the same run keeps 572 MB. A schema compiled again after its entry was dropped is compiled again.
With both
$defsanddefinitionsat the root and the same name in both, generated code read#/definitions/xas$defs/x, so a document the schema accepts was rejected and one it rejects was accepted. Such a schema now runs on the interpreted engine, which resolves each pointer as written. Affected: every version from 0.6.0 to 1.42.0. None of the 980 SchemaStore schemas has both containers.
Fixed
validateJSON()andisValidJSON()could abort the whole Node process on large schemas with many references to the same definitions, such as SchemaStore's traefik and detekt. The text scanner generated for such a schema came to 0.5 to 1 MB of source in one function, and V8's Maglev compiler aborted the process (raw_hash_map<>::at) while optimizing it, after about a hundred calls, in plain JavaScript on Node 24 and 25. It was intermittent, about half of the runs on those schemas. Affected: 1.30.0 to 1.42.0, measured on the published versions. The scanner is now emitted as one function where it fits in 64 KB and otherwise split into functions of bounded size, with large definitions and members called rather than repeated; the largest scanner function on SchemaStore went from 1 MB to 64 KB. Every one of the 980 SchemaStore schemas was run in its own process, with and without the native addon, with no failure. On traefik-v2 the split scanner is 1.47 times slower than parsing the text would be; on the six other schemas that split it is 1.0 to 2.3 times faster.The compile cache and the preprocess cache were unbounded. They kept every schema's text and generated functions for the life of the process: after compiling each of the 975 SchemaStore schemas once and dropping the validators, 1247 MB stayed on the heap. Both caches now keep at most 128 entries, oldest out first, and the same run keeps 572 MB. A schema compiled again after its entry was dropped is compiled again.
With both
$defsanddefinitionsat the root and the same name in both, generated code read#/definitions/xas$defs/x, so a document the schema accepts was rejected and one it rejects was accepted. Such a schema now runs on the interpreted engine, which resolves each pointer as written. Affected: every version from 0.6.0 to 1.42.0. None of the 980 SchemaStore schemas has both containers.A property name holding a control character, such as a line break, broke the source of the function that validates and collects errors in one pass. Answers were right, but such a schema validated a rejected document twice every time its errors were read.
Changed
Reading the errors of a rejected document is faster again. On the official test suite, validating every case and reading every error, a run takes 1.12 times less time on Draft 2020-12 and 1.05 times less on draft 7 with the default errors, and 1.18 and 1.05 times less with
richErrors: false. Measured against 1.42.0 in one harness on an Apple M4 Pro with Node 25. Where it comes from:oneOfandanyOfcount their passing branches with verdict functions and run the branches that collect errors only when none passes; aoneOfmatched by more than one branch builds itsATA4002error from that count.Where
unevaluatedPropertiesis worked out at run time, its branch counts are reused byoneOfandanyOf, and each branch's validity is worked out once, bottom up.
More schemas get one-pass validation, or generated code at all:
recursive schemas, through
$ref: "#"or definitions on a reference cycle;unevaluatedItemswhose evaluated positions depend on which branches pass;a
$refbeside keywords that validate, such as{ "$ref": "#/$defs/n", "minimum": 5 }, which Draft 2020-12 applies together; it used to send the whole schema to the interpreted engine;patterns with Unicode property escapes such as
\p{L}, compiled with the unicode flag as the interpreted engine compiles them; they used to send the whole schema to the interpreted engine.
Of the 980 SchemaStore schemas, 840 run on generated code where 838 did.
A definition referenced from several places is written once as a function and called at each reference, in the function that collects errors, and in the verdict function when its source would pass 64 KB. The first rejection with its errors read (16 calls, compiling included, plain JavaScript) takes 85 ms on SchemaStore's SARIF schema where it took 128 ms, 33 ms on cloud-run where it took 92 ms, and 40 ms on putout where it took 150 ms.
The text scanner behind
validateJSON()andisValidJSON()covers 85 of the 980 SchemaStore schemas, up from 62, now that it reads draft 7definitions, and finds long property names withindexOf. Over the 48 of those schemas with sample documents, each in its own process,validateJSON()takes 0.88 of the time it took, as a geometric mean.ata buildused to refuse a schema with a$refbeside a validating keyword. It now emits a module whose verdict applies both, withoutparseand with the singleATA9000abort-early error in place of detailed errors, and says so for each throughonWarning.Cost of the above:
the browser runtime bundle is 114.1 KB gzipped, up from 108.9 KB;
requiring the package, compiling a schema and validating once takes about 0.35 ms longer (8.07 ms where it took 7.70, plain JavaScript), from the larger code generator;
a schema that now gets the one-pass function compiles it at its first rejections: on SchemaStore, partial-eslint-plugins takes 195 ms over its first 16 calls where it took 145, and apollo-router 120 ms where it took 109.
Validating an accepted or rejected document, warm, is unchanged within noise. Modules from
ata buildare byte for byte the same size.
1.42.0
4 Oct 2026Reading the errors of a rejected document is faster. On the official test suite, validating every case and reading every error of each rejection, a run takes 1.4 times less time on Draft 2020-12 and 1.5 times less on draft 7 with the default errors, and 1.75 and 2.1 times less with
richErrors: false. Measured against 1.41.0 in one harness, on an Apple M4 Pro with Node 25. Where it comes from:More schemas run on generated code instead of the interpreted engine. Every
$refthe interpreted engine can resolve (a pointer deeper than a top-level definition, an$id-relative or absolute URI, a URN, an anchor) is resolved at compile time by that engine's own resolver, andunevaluatedPropertiesthat depends on which branches pass is evaluated at run time in generated code. Of the 980 SchemaStore schemas, 838 compile where 797 did, tsconfig.json and jsconfig.json among them. On the official suite, 326 of 384 Draft 2020-12 groups compile where 305 did, and 238 of 258 draft 7 groups where 219 did.Cost of the above: the browser runtime bundle is 108.9 KB gzipped, up from 102.9 KB, and loading the package takes about 0.2 ms longer, both from the larger code generator. Modules from
ata buildare byte for byte the same size. A Fastify app with 10 routes reaches its first validated request 4% sooner.
Changed
Reading the errors of a rejected document is faster. On the official test suite, validating every case and reading every error of each rejection, a run takes 1.4 times less time on Draft 2020-12 and 1.5 times less on draft 7 with the default errors, and 1.75 and 2.1 times less with
richErrors: false. Measured against 1.41.0 in one harness, on an Apple M4 Pro with Node 25. Where it comes from:Once a validator's errors are being read,
validate()withrichErrors: falsedecides and collects in one pass, and withrichErrors: trueeach error is built enriched where it fails rather than plain and enriched afterwards. A validator whose errors are never read is unaffected, and reading only.validstill costs the verdict alone.Past its first calls,
validate()is a function generated for the schema rather than shared wrappers around the generated checks.oneOfandanyOf(with the collapsedATA4001toATA4003error),additionalPropertiesas a schema, andunevaluatedPropertieswhose evaluated set depends on which branches pass now compile into the function that collects errors.A
uniqueItemserror, a collapsedoneOforanyOferror, and aconsterror on an object or array value cost a fraction of what they did.
More schemas run on generated code instead of the interpreted engine. Every
$refthe interpreted engine can resolve (a pointer deeper than a top-level definition, an$id-relative or absolute URI, a URN, an anchor) is resolved at compile time by that engine's own resolver, andunevaluatedPropertiesthat depends on which branches pass is evaluated at run time in generated code. Of the 980 SchemaStore schemas, 838 compile where 797 did, tsconfig.json and jsconfig.json among them. On the official suite, 326 of 384 Draft 2020-12 groups compile where 305 did, and 238 of 258 draft 7 groups where 219 did.Cost of the above: the browser runtime bundle is 108.9 KB gzipped, up from 102.9 KB, and loading the package takes about 0.2 ms longer, both from the larger code generator. Modules from
ata buildare byte for byte the same size. A Fastify app with 10 routes reaches its first validated request 4% sooner.
Fixed
validateJSON()returned a barefalseinstead of a result for an invalid document when any string in the schema contained the wordfunction, such as a property namedfunction. Code that checksif (!result.valid)behaved correctly, but readingresult.errorsthrew, and a check written asresult.valid === falselet the document through. The generated code is rewritten as text to build that path, and the rewrite read the word inside a string as the start of a function. Strings are now skipped. Affected: 1.23.0 to 1.41.0.validate(),isValidObject()andisValidJSON()were not affected.tests/test_generated_returns_rewrite.jsputs the words and characters that rewrite looks for into property names, enum values and patterns, and checks every entry point against the interpreted engine.A
uniqueItemserror named a different duplicate pair depending on the engine that answered. Every engine now reports the first item that has a later equal, and its first later equal (params: { i, j }), as the interpreted engine did. The message for a document checked by generated code can name a different pair than before.Generated code and the interpreted engine gave different error lists for the same document in a few shapes; what is accepted did not differ. A value of the wrong type is now also checked against the keywords that apply to its own type (
minimumfor2.5againsttype: "integer",allOfandiffornullagainsttype: "object"), a collapsedoneOforanyOfunder a pattern sorts in schema order, andtypewritten as a one-element array is reported as an array. The SchemaStore sample documents, 31080 of them with mutated copies, now get identical error lists from both engines; three differed before.An
isValidObjecttaken before the validator's first call, as inconst check = v.isValidObject, classified the schema again on every call, about ten times the cost of the check. It now hands straight to the compiled check.
1.41.0
4 Oct 2026Reading the note…
1.40.1
30 Sep 2026Reading the note…
1.40.0
30 Sep 2026Reading the note…
1.39.2
30 Sep 2026Reading the note…
1.39.1
29 Sep 2026Reading the note…
1.38.0
29 Sep 2026Reading the note…
1.37.1
29 Sep 2026Reading the note…
1.37.0
29 Sep 2026Reading the note…
1.36.1
28 Sep 2026Reading the note…
1.36.0
28 Sep 2026Reading the note…
1.35.0
28 Sep 2026Reading the note…
1.34.0
28 Sep 2026Reading the note…
1.33.3
28 Sep 2026Reading the note…
1.33.2
28 Sep 2026Reading the note…
1.33.1
28 Sep 2026Reading the note…
1.33.0
28 Sep 2026Reading the note…
1.32.3
27 Sep 2026Reading the note…
1.32.2
27 Sep 2026Reading the note…
1.32.1
27 Sep 2026Reading the note…
1.32.0
26 Sep 2026Reading the note…
1.31.3
26 Sep 2026Reading the note…
1.31.2
26 Sep 2026Reading the note…
1.31.1
26 Sep 2026Reading the note…
1.31.0
26 Sep 2026Reading the note…
1.30.3
25 Sep 2026Reading the note…