JSON Schema validation where the TypeScript type comes from the schema, or the schema from the type. Write the schema once and validate narrows to it; write the type once and the bundler plugin writes its schema in at build time. The compiled and interpreted engines answer identically, at 100% of the official suite in both modes, across Draft 2020-12, 2019-09, drafts 7, 6 and 4 and the JSON Schema v1 dialect, and ata build emits a standalone module that imports nothing, with its .d.mts beside it.
1.0 is a stability commitment: see docs/STABILITY.md for the semver, deprecation, and error-code guarantees.
One source of truth, in either direction. A schema written with defineSchema carries its
type: validate narrows data to it, and Infer pulls the type out for a signature.
import { defineSchema, Validator, type Infer } from 'ata-validator'
const User = defineSchema({
type: 'object',
properties: {
id: { type: 'integer', minimum: 1 },
email: { type: 'string', format: 'email' },
role: { enum: ['admin', 'user'] },
},
required: ['id', 'email'],
})
const users = new Validator(User)
const r = users.validate(JSON.parse(body))
if (r.valid) {
r.data.id // number
r.data.role // 'admin' | 'user' | undefined
r.data.name // a type error: the schema has no name
}
type User = Infer<typeof User>Or start from the type. schemaOf<T>() has no run-time form; the bundler plugin reads T
with the TypeScript checker and writes its JSON Schema in place of the call, with the JSDoc
tags carrying what a type cannot say, and the new Validator(...) then compiles away like any
static schema.
import { Validator, schemaOf } from 'ata-validator'
interface Account {
/** @minimum 1 */
id: number
/** @format email */
email: string
role?: 'admin' | 'user'
}
const accounts = new Validator(schemaOf<Account>())ata build writes a .d.mts next to every compiled module, with the inferred type and an
isValid predicate on it, so a module that imports nothing still narrows. The chainable
builder in ata-validator/t, Infer on anyOf, allOf, tuples and recursive $ref, and
the async t.refine are in Type-safe schemas; the plugin's type reading
is in its README.
Projects whose code depends on ata, from GitHub's dependency graph, a framework that lists its plugin, a form library that ships its resolver, and one company whose use is not public code, with what each does with it:
- Trendyol validates configuration with ata in Puzzle, its internal config tooling. The package is internal, so it is not in the public dependency graph; this line rests on its maintainer.
- Socket uses ata, compiled ahead of time, in the shared build tooling of its repositories.
- React Hook Form ships an ata resolver in
@hookform/resolvers(@hookform/resolvers/ata-validator), beside its other validators. - Fastify lists the
fastify-ataplugin in its ecosystem. - react-jsonschema-form ships an ata validator in its main repository, in a runtime and a precompiled form.
- JollyPixel parses and validates JSON on its back end with ata, compiled ahead of time.
- better-drizzle ships an ata plugin for queries and rows.
- svelte-jsonschema-form publishes an ata validator package, runtime and precompiled.
npm install --save-dev ata-validator
npx ata build 'schemas/*.json' --out-dir src/generatedThe ata-validator package itself is pure JavaScript. The native accelerator (simdjson parsing, parallel NDJSON, buffer APIs) ships as per-platform optional packages that npm installs automatically where they fit, the same pattern Vite uses for esbuild. Seven targets are built: macOS on arm64 and x64, Linux on x64 and arm64 against both glibc (2.17 or later, below the 2.28 Node itself needs) and musl, and Windows on x64. A platform without a prebuild still installs and validates, on the pure-JS engine. For a guaranteed zero-binary install:
npm install ata-validator --omit=optionalor set ATA_NO_NATIVE=1 at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some $dynamicRef, cyclic $ref, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one, 1302 of 1302 Draft 2020-12 cases. The buffer and parallel APIs (isValid on raw buffers, isValidPrepadded, isValidNDJSON, isValidParallel, countValid, batchIsValid, validateAndParse) work without the addon too, answering through the same checks as isValidJSON(), slower than the addon: 177 ns against 92 for a small document on Node 25. In a browser, which has no Buffer, they throw and name the methods to use instead.
In Cloudflare Workers and Vercel Edge, where generated code cannot run, ata-validator
resolves to an entry without the code generator through the workerd and edge-light export
conditions: the same names and answers, and a Worker bundled by wrangler goes from 179.1 to
70.4 KiB gzipped.
In a browser, compile with the bundler plugin: see In the browser.
Without the plugin, the code generator is still not part of the page. Every schema starts on the
interpreted engine, which gives the same answers, and a validator called a thousand times within
a second loads the generator in the background, through a dynamic import that Vite, webpack and
Rollup put in a chunk of their own, and is rebuilt on generated code when the page is idle. A
form, checked as the user types, never loads it. On a react-jsonschema-form page this takes the validator's
share of the first load from 134.7 KB to about 55 KB gzipped; the generator chunk is about 78 KB
more when it does load, and the plan compiler, which speeds up a schema the interpreted engine checks
often, comes in a 6 KB chunk once the page is idle. Where unsafe-eval is blocked, on a page with a strict
Content-Security-Policy or in a Manifest V3 extension, it is never fetched and every schema
stays on the interpreted engine. To have generated code from the first call, import
ata-validator/codegen once; it also makes parse() available in the browser, which otherwise
waits for the generator.
On Deno the same package is npm:ata-validator or jsr:@ata/validator; the JSR package
re-exports the npm one at the same version, and CI runs the official suite under Deno. The
details, and what Deno Deploy's refusal of dynamic code means for it, are in
docs/edge-runtimes.md.
Those four agree with validate() on all 3366 cases of the Draft 2020-12, draft-07 and v1 suites, the three dialects the buffer-path differential runs. The native walker behind them does not handle every shape (contains, unevaluatedProperties, patternProperties, tuple items, cross-document $ref, a few formats), so for schemas using one of those the buffer APIs parse the bytes and answer through validate(); the list is in lib/buffer-gate.js. Typical request schemas stay on the zero-copy path. npm test holds the disagreement count at zero.
Where new Function is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores the same 1302 of 1302 with code generation blocked. No flags, and on Workers no nodejs_compat either. See docs/edge-runtimes.md. Where generated code runs but slowly, as in an ahead-of-time compiled Bun executable, a module from ata build is plain JavaScript that imports nothing and calls no new Function: on Bun 1.4.0, import plus a first validation takes 0.66 ms with such a module against 10.70 ms with the runtime compiler (medians of 21 runs). docs/proof-no-code-generation.md runs one schema in five of these places, Workers and a CSP page included, and shows the same answers from each.
Node has a switch for exactly that environment, so this takes thirty seconds to check for yourself, on ata or on whatever you use today:
node --disallow-code-generation-from-strings -e "
const { Validator } = require('ata-validator')
const v = new Validator({ type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] })
console.log(v.validate({ id: 1 }).valid, v.validate({ id: 'x' }).valid)
"A validator that reaches its speed by generating source and calling new Function cannot run under that flag at all, which is the same reason it cannot run on a Worker. That is a deliberate trade rather than an oversight, and it is worth knowing which side of it your validator is on before you deploy to an edge runtime.
In your code:
import { validate, isValid, type User } from './generated/user.compiled.mjs'
if (isValid(req.body)) {
const user: User = req.body
// ...
}The .compiled.mjs modules are self-contained: zero runtime dependency on ata-validator, fully tree-shakeable, with TypeScript types emitted alongside.
Code already written with new Validator(schema) gets the same result from the bundler plugin, without changes: @ata-project/unplugin replaces each call whose schema is known at build time with a compiled validator that answers the same, and the runtime leaves the bundle. The sizes are in In the browser. Schemas that arrive at runtime keep the runtime, which is the right tool for them.
Add the bundler plugin. @ata-project/unplugin compiles each new Validator(schema) whose schema it can read at build time, so the page carries that schema's checks and not the compiler. Your code does not change, and the answers, the errors and the filled defaults are the same.
// vite.config.js
import ata from '@ata-project/unplugin/vite'
export default { plugins: [ata()] }// next.config.mjs, for Turbopack (the default since Next.js 16) and webpack
import withAta from '@ata-project/unplugin/next'
export default withAta({ /* your config */ })Webpack, Rspack, Rollup, Rolldown, esbuild and Bun take the same plugin; see its setup.
An order form with six fields, an email format, a pattern and a default, its errors read on submit:
| gzipped | ready, phone profile | first submit | |
|---|---|---|---|
| without the plugin (code split) | 55.3 KB | 779 ms | 13.9 ms |
| with the plugin | 4.5 KB | 372 ms | 1.6 ms |
The sizes are an esbuild build, minified, gzip level 9. The times are Chrome with Lighthouse's mobile throttling (CPU 4x slower, 1.6 Mbps, 150 ms round trip), the files served gzipped, medians of 11 runs; the submit is made as soon as the page has loaded, before the runtime warms itself up in idle time, so a later submit on the runtime takes less than that.
What a page's validators add to its first load, gzipped, in applications built the usual way (Vite + React, Next.js 16), forms of six fields reading their errors on submit:
| ata 1.62.1, plugin 0.11.3 | ata 1.63.0, plugin 0.12.0 | |
|---|---|---|
| Vite, one form | 2.33 KB | 1.41 KB |
| Vite, ten forms | 8.02 KB | 2.02 KB |
| Vite, ten forms with email and uri fields | 12.07 KB | 2.70 KB |
| Next.js with webpack, one form with rich errors | 10.71 KB | 4.63 KB |
| Next.js with Turbopack, one form with rich errors | 67.52 KB (the plugin could not run) | 4.23 KB |
Many forms go out as data for one shared interpreter instead of code, where that makes the bundle smaller; the plugin decides for the whole application, and keeps written code for a single form. Needs ata-validator 1.63.0 and the plugin's 0.12.0.
A page that writes its schemas in zod, valibot or TypeBox gets the same build-time compile from the plugin's 0.13.0 (2026-11-06). ata({ zod: 'src/events/*.ts' }) takes modules that export zod 4 schemas, ata({ from: 'src/schemas/*.ts' }) takes zod, valibot or TypeBox modules; the build loads each module, converts every exported schema with the library's own converter and writes <module>.validators.mjs and .d.mts beside it, one { validate, isValidObject } per export, typed with the library's own types. The page keeps the library it writes schemas in and carries neither it nor a validator runtime. A .refine(fn), .transform(fn), valibot check or transform that reads nothing but its own parameter is carried and runs on the page in the library's order; what cannot be carried (a function closing over an outside name, superRefine, preprocess, a refine inside a union, a TypeBox kind outside the standard set, among others) stops the build by export and path, and dropRefinements: true takes them out knowingly. Measured with esbuild, minified, gzip level 9, on ata-validator 1.63.1: three event schemas on zod 4.6.7, the page carries 3.9 KB with the compiled validators and 93.0 KB with zod; one schema alone 1.1 KB; three carried refines on a signup schema add 0.5 KB. TypeBox 0.34.52: 1.1 KB compiled against 25.5 KB with Value.Check on the page. valibot 1.5.0: 1.1 KB against 1.7 KB. Not covered: zod 3, arktype, effect, and the stripping of unknown keys, which the page leaves in place where z.object would drop them (strictObject stays closed).
With an OpenAPI document the plugin writes the validators from it (ata({ openapi: 'openapi.json' }), request bodies by operation, see its README); a server reads the same document through operationSchemas in ata-validator/openapi.
The form and the server decide alike. A validator the plugin compiles answers exactly as new Validator(schema) does on the server, the same schema read the same way, when the page is built with the same ata-validator version the server runs: on a nine-field signup schema (email, uri, a pattern, lengths counted in characters, a fixed set of fields) the compiled form and the server's validator disagreed on 0 of 50,000 generated documents, so a submit the page accepts is one the server accepts.
A schema that arrives at run time (a form builder, a CMS, a schema served by the backend) can arrive as a plan instead. The server writes it with tablePlan from ata-validator/ir, which does the heavy work (references, dialects, normalization); the page reads it with fromPlan from ata-validator/plan, which carries the table interpreter and loads the format checks a plan names when it names them. Verdicts, errors and filled defaults are the server's for the same schema.
// server
import { tablePlan } from 'ata-validator/ir'
app.get('/forms/signup', () => tablePlan(signupSchema, { richErrors: false }))
// page
import { fromPlan } from 'ata-validator/plan'
const v = await fromPlan(await (await fetch('/forms/signup')).json())
v.validate(data)For the nine-field signup schema above, a Vite page that receives its schema at run time loads 53.0 KB of JavaScript gzipped with new Validator(schema) and 6.2 KB with fromPlan, the errors on the page the same. ata-validator/plan-rich reads plans with ata's rich errors as well. On ata-validator 1.63.1 a plan takes the keywords forms use: types, properties, required, additionalProperties, enum, const, the string, number and array bounds, format, pattern, nested objects and arrays, allOf, $ref within the document, not, if, dependentRequired, and defaults; tablePlan returns null for anything else (a $ref to another document, the unevaluated keywords, dynamic references, the keywords below), and such a schema keeps new Validator on the page. A name the engine ignores, such as a vendor's own keyword, means nothing to a plan either. The next release carries patternProperties, propertyNames, contains, anyOf, oneOf, dependentSchemas and propertyDependencies as well, which on the current SchemaStore covers 844 of 990 schemas and all 144 of its flat object schemas of twelve properties or fewer; that is not in 1.63.1. A plan is as trusted as a schema, so serve it from your own server: fromPlan refuses one that is not well formed before it runs, and says so once when a plan was written by another ata-validator version, whose answers may differ.
Public harnesses run ata without ata's involvement. Quote these before anything in this file.
- The runtime type benchmark maintained by moltar, 44 to 56 libraries per test. In its official run on Node 24, with ata 1.32.0, ata is first of 45 on assertStrict at 37.2M operations a second, and its ahead-of-time entry is first of 44 on parseStrict at 36.3M, with the runtime entry second. On assertLoose ata is fifth of 56 and on parseSafe sixth of 45. Some of the entries ahead of it on those two rows skip checks JSON Schema requires there, such as rejecting an array where an object is declared or NaN where a number is. On Deno the same run puts ata between 12th and 23rd; 1.32.1 makes it about 2.5 times faster there, locally, and had not been through the harness yet at the time of writing.
- schemabenchmarks.dev, a benchmark of runtime validation
libraries. Its published run of 2026-09-25 still uses ata 1.29.0 and puts it sixth of 30 on
valid data at 870 ns and tenth on invalid data at 67 ns. Most of that time was the
uriformat check and a wrapper in the keywords package, both rewritten since. Run locally on an Apple M4 Pro, the same harness takes ata from 365 ns on 1.29.0 to 240 ns on 1.32.1 for valid data, with invalid data at 21 ns on both; those are local figures, not the site's. - Bowtie, the cross-implementation JSON Schema test harness. ata's harness runs Draft 2020-12 and draft 7 there; on the harness at ata 1.16.1 the official suite passes with nothing failed, errored or skipped under Bowtie's own runner. The v1 dialect is declared in a pending harness change that waits for a Bowtie release.
validator-benchmarks is ata's own comparison, so it comes after the ones above: eleven validators on three realistic bodies and four scenes, each library in its own process, 27 samples per cell, every schema and document in the repository. On an order body with formats and a pattern, 900 valid documents, Node 24:
| documents a second, the verdict | same, new Function refused |
|
|---|---|---|
| ata | 3,274,803 | 1,109,881 |
| ata, compiled module | 3,270,947 | 3,353,351 |
| typia | 1,957,392 | 1,931,134 |
| typebox | 1,615,239 | EvalError |
| ajv | 1,245,508 | EvalError |
| zod | 436,632 | 230,808 |
Run of 2026-10-30 with typia 15.2.2, which reuses a pattern's RegExp and shortcuts length checks after a report from this benchmark; its order figure doubled.
The same repository runs every SchemaStore schema that ships sample documents, 446 of them,
against the default validator on those samples: ata ahead on 430, median 1.83x, 2.82x on
schemas of 50 to 200 KB, and never below 0.74x on the 16 where it is behind
(RESULTS-schemastore.md,
Node 24, ata 1.61.3). Where ata is behind is
in the same tables: on a four-field body that fails, ajv answers the verdict faster (15.5M
against 10.4M), and with every error read it does 7.1M a second against ata's 3.6M with rich
errors, 5.2M with richErrors: false.
| Dimension | Schema | ata-AOT | runtime validator | Difference |
|---|---|---|---|---|
| Bundle (gzipped) | simple | 1.4 KB | 52.7 KB | 37.6x smaller |
| Bundle (gzipped) | complex | 2.9 KB | 52.7 KB | 18.2x smaller |
| Bundle (gzipped) | nested | 4.5 KB | 52.7 KB | 11.7x smaller |
| Cold start | simple | 21 ms | 38 ms | 1.8x faster |
| Throughput (1M ops) | simple | 317 Mops/s | 109 Mops/s | 2.9x faster |
| Compile time | simple | 39 µs | 1.57 ms | 40x faster |
The runtime column is the default validator most frameworks ship. Reproduce on your machine
with npm run bench:aot-vs-ajv. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-11-06,
on ata-validator 1.63.1; the complex and nested schemas in the same run: cold start 21 and 20 ms
against 41 and 40, throughput 26.3 and 18.6 Mops/s against 13.4 and 4.0, compile 66 and 126 µs
against 1.62 and 1.73 ms. On 1.51.0, across five runs, throughput moved between 263 and 325 Mops/s
against 97 to 110, cold start between 20 and 22 ms against 37 to 40, and the compile ratio between
61x and 65x. The compile row is the time to write a module, a build-time cost; it was 18 µs on
1.46.0, and modules have carried the one-pass error function since 1.47.0. The throughput row times a single one-million-call
loop of a few milliseconds, so it moves the most from run to run; treat the last three rows as
an order of magnitude rather than a constant.
The wins are largest on bundle size and compile time because AOT moves work from runtime to build time. Throughput and cold start are also faster because the compiled validator is a tight straight-line function with no schema-walk overhead.
What the table does not cover is data that fails. It measures compiled modules on input that
passes, and a rejection costs more than a verdict: ata builds an error carrying a code, the
offending value, a documentation link and a suggestion. On a five-field object schema a
passing payload costs about 17 ns and a rejected one about 155 ns. abortEarly: true or
isValidObject() skips that work when only the verdict matters. Schemas ata declines to
compile, mostly cross-document $ref, $dynamicRef and the harder unevaluated* shapes,
run on the interpreted engine. That engine compiles each schema into a tree of closures, so
on a six-field object schema a passing payload costs about 136 ns against 8 ns for the same
shape on the compiled path, both measured warm in one run on the same machine.
If you ask a model for structured output and validate the result, the schema is part of the prompt and the validation error is part of the retry. Two functions produce those strings.
import { describeSchema, toRetryMessage, Validator } from 'ata-validator'
describeSchema(schema)
// output: object
// status: one of "AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"
// region_code: one of "R11", "R24", "R37", "R52"
// amount: integer, at least 1
// no other fields
const r = new Validator(schema).validate(fromTheModel)
if (!r.valid) toRetryMessage(r.errors)
// /status: expected one of ["AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"], found "partial"
// /region_code: expected one of ["R11", "R24", "R37", "R52"], found "HH"
// /amount: expected ≥1, found 0
// /: unknown property "extra"Whether this is worth anything depends on the schema, and the measurement that says so is public, with the harness and the raw results.
On constraints a model can work out from the document, a pattern, a date format, a currency in uppercase, it changes nothing: 40 of 40 documents recovered on one retry with the conventional error text, and 40 of 40 with the detailed one.
On constraints it cannot work out, an enum of internal codes while the
document says "paid in part" and "Hamburg office", the conventional text
recovered 0 of 30 and the detailed text 30 of 30. The model does not give up
when it is not told the values, it invents plausible ones, so no number of
retries closes it.
Stating the constraints up front does the same job earlier. Across four prompts
on the same fixtures, first attempts that validate: nothing 0 of 30, a lean
field list 0 of 30, a careful hand-written description 30 of 30 on the inferable
fixture and 0 of 30 on the opaque one, and describeSchema 30 and 23. A person
writing prose matches the generated description wherever prose works. It is the
enumerated values a person leaves out.
One model, Claude Haiku 4.5, one schema shape, 30 to 40 documents per arm, no temperature control. Read it as a finding, not a law.
ata's error output is compiler-grade: each error carries a stable code, an inline source frame pointing at the schema file, and another pointing at the offending bytes in the request payload. Renderers ship in three styles:
import { Validator, renderPretty, renderCompact, renderJSON } from 'ata-validator'
const v = new Validator(schema, { source: { path: 'schemas/user.json', content: schemaText } })
const r = v.validateJSON(input)
if (!r.valid) {
console.error(renderPretty(r.errors))
// error[ATA3001]: not a valid email: "not-an-email"
// --> schemas/user.json:5:44
// |
// 5 | "email": { "type": "string", "format": "email" },
// | ^ expected format 'email'
// |
// --> input:1:24 (body.email)
// |
// 1 | {"name":"Mert","email":"not-an-email","age":26}
// | ^^^^^^^^^^^^^^ found "not-an-email"
// |
// = help: missing '@' and domain part
// = note: see https://ata-validator.com/e/ATA3001
}The ata CLI ships ata validate <schema> <data> for one-off checks. TTY auto-renders pretty; pipes default to compact; --format=json produces structured output for tooling.
Errors carry a stable code field (ATA####), see the error code registry. Each code has a permalink at https://ata-validator.com/e/<CODE>.
A subschema can override the human-facing message with an errorMessage keyword. A string replaces the message for any failing keyword on that subschema; an object overrides per keyword, with required keyed by the missing property name (or a single string) and _ as a fallback. The code, keyword, and path fields are untouched, so dashboards and renderers keep working.
const v = new Validator({
type: 'object',
properties: {
age: { type: 'integer', minimum: 18, errorMessage: { minimum: 'must be 18 or older', type: 'age has to be a number' } },
email: { type: 'string', format: 'email', errorMessage: 'enter a valid email address' },
},
required: ['email'],
errorMessage: { required: { email: 'email is required' } },
})
v.validate({ age: 5 }).errors[0].message // 'must be 18 or older'
v.validate({}).errors[0].message // 'email is required'Schemas without an errorMessage keyword pay nothing: the override pass is only installed when one is present.
For consumers who built log dashboards on the v0.14 error shape, new Validator(schema, { richErrors: false }) returns the legacy shape exactly. For high-throughput paths, abortEarly: true continues to short-circuit; the returned error carries code: 'ATA9000' and no enrichment.
There are two ways to run ata and they suit different places. Measuring the wrong one is the most common way to get a misleading number out of this library.
compiled with ata build |
runtime new Validator(schema) |
|
|---|---|---|
| In a bundle, gzipped | 2.2 KB | 131.1 KB |
| Time to a served request | 3.5 ms | 6.9 ms |
| Schema known when | build time | any time |
The bundle row is the ten-field user schema in
tests/fixtures/error-dx/user.schema.json, every export of the compiled module against
new Validator(schema), built with bun build --minify --target=browser on ata 1.54.0.
The startup row is a Hono route on Bun 1.4, the median of three rounds of best-of-seven, from
benchmark/bundle, against 3.4 ms for the same app doing no validation at all, so the
compiled path costs nothing measurable to start. The runtime figure is what it is because
a schema that arrives at run time can use any keyword, so the whole engine has to be
there. The compiled module imports nothing and contains only the checks your schema asks
for.
On a server, use whichever fits your schemas. 131 KB of JavaScript on a Node or Bun process is not a cost anyone notices, and the runtime API is the simpler thing to reach for. Speed is the same either way once warm.
On Node 22 or later, a process that starts often can keep V8's compiled code between
starts. Point NODE_COMPILE_CACHE at a directory that survives the restart, or call
module.enableCompileCache() in your entry. Measured on an Apple M4 Pro, Node 25.2.1:
loading ata goes from 4.9 to 2.7 ms, and a fresh process reaches its first validated
document in 5.3 ms instead of 7.8. This is Node's feature, it covers every module the
process loads, and ata does not turn it on for you.
In a browser, on an edge runtime, or anywhere cold starts are charged, compile. This
is where the difference is the whole story, and it is also where new Function is often
blocked outright, which the compiled module does not need. With a bundler, adding @ata-project/unplugin
is enough: it compiles new Validator(schema) calls at build time, and leaves the rest alone.
ata build is for schemas you know at build time. If your schemas are user-supplied at runtime (form builders, no-code platforms, dynamic API ingestion), use the runtime API:
import { Validator } from 'ata-validator'
const v = new Validator(schema)
const result = v.validate(data)The runtime API is unchanged from previous releases. Code written against the default validator's class keeps working through ata-validator/compat, which covers compile, addSchema, addFormat, addKeyword, errorsText and the rest of that surface, and reports errors in the same shape and order. npx ata migrate reports what the switch would change in a project, and what it cannot translate, before anything is written. See docs/migration-from-ajv.md.
Coming from the default validator, one command reports what the switch touches and a second applies the mechanical part:
npx ata migrate # per file: imports to rewrite, addFormats() calls that go, sites to look at by hand
npx ata migrate --write # rewrites the imports to ata-validator/compat and removes the calls, leaves the restCoercion, defaults, allErrors and the error objects behave as before; the details and the limits are in docs/migration-from-ajv.md.
const { Validator } = require('ata-validator');
const v = new Validator({
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0 },
role: { type: 'string', default: 'user' }
},
required: ['name', 'email']
});
// Fast boolean check, compiled to JS
v.isValidObject({ name: 'Mert', email: 'mert@example.com', age: 26 }); // true
// Full validation with error details + defaults applied
const result = v.validate({ name: 'Mert', email: 'mert@example.com' });
// result.valid === true, data.role === 'user' (default applied)
// JSON string validation (simdjson fast path)
v.validateJSON('{"name": "Mert", "email": "mert@example.com"}');
v.isValidJSON('{"name": "Mert", "email": "mert@example.com"}'); // true
// Buffer input (zero-copy, raw NAPI)
v.isValid(Buffer.from('{"name": "Mert", "email": "mert@example.com"}'));
// Which engine answers this schema: 'codegen', 'closure', 'native' or 'interpreter'
v.engine(); // 'codegen'
// Parallel batch, multi-core, NDJSON
const ndjson = Buffer.from(lines.join('\n'));
v.isValidParallel(ndjson); // bool[]
v.countValid(ndjson); // numberata infers TypeScript types straight from plain JSON Schema, as in the opening example: defineSchema returns the schema untouched at runtime and, in TypeScript, gives keyword autocomplete and an error when a value has the wrong shape, with no as const needed. new Validator(schema) carries the inferred type, so a successful validate narrows result.data with no manual annotation.
You can also pull the type out directly with Infer, with no second declaration to keep in sync.
import { defineSchema, type Infer } from 'ata-validator'
const event = defineSchema({
$defs: {
Point: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'] },
},
type: 'object',
properties: {
kind: { enum: ['click', 'scroll'] },
at: { $ref: '#/$defs/Point' },
path: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'integer' }] },
},
required: ['kind', 'at'],
})
type Event = Infer<typeof event>
// {
// kind: 'click' | 'scroll'
// at: { x: number; y: number }
// path?: [string, number]
// }Infer resolves const/enum to literals, anyOf/oneOf to unions, allOf to intersections, prefixItems to tuples, and local $ref into #/$defs or #/definitions, including recursive references. An external or unresolvable $ref resolves to unknown rather than erroring. The exported JSONSchema type is available if you want to annotate a schema by hand; custom and vendor keywords are allowed. Requires TypeScript >= 5.0.
If you prefer a chainable builder over JSON Schema literals, ata-validator/t ships one whose output is still plain JSON Schema. The runtime validator, Infer<S>, and the AOT pipeline all keep working without an adapter. The migration from TypeBox is one import rename, then the same authoring shape:
import { t } from 'ata-validator/t'
import { Validator, type Infer } from 'ata-validator'
const User = t.object({
id: t.integer(),
name: t.string({ minLength: 1 }),
email: t.optional(t.string({ format: 'email' })),
role: t.union([t.literal('admin'), t.literal('user')]),
})
type User = Infer<typeof User>
// { id: number; name: string; role: 'admin' | 'user'; email?: string }
const v = new Validator(User)The builder covers primitives (string, number, integer, boolean, null), composites (object with optional keys, array, tuple, record, union, intersect, literal, const, enum), plus the TypeBox-style modifiers pick, omit, partial, required, composite, and recursive. Optionality is carried by a Symbol marker that the emitted JSON Schema and ata's codegen never see, so the output is still a plain JSON Schema literal that you can pass to anything that takes one.
const User = t.object({ id: t.integer(), name: t.string(), email: t.optional(t.string()) })
const Patch = t.partial(t.omit(User, ['id']))
type Patch = Infer<typeof Patch>
// { name?: string; email?: string }JSON Schema is synchronous, so checks that need to await, a uniqueness lookup, a remote call, a cross-field rule, attach to a schema with t.refine and run through validateAsync. The refinement rides on a Symbol marker, so new Validator(schema) still does plain structural validation and ignores it; only validateAsync/parseAsync evaluate it, and only after the value is structurally valid.
import { t } from 'ata-validator/t'
import { validateAsync, parseAsync } from 'ata-validator'
const Signup = t.refine(
t.object({ username: t.string({ minLength: 3 }), email: t.string({ format: 'email' }) }),
async (value) => !(await usernameTaken(value.username)),
{ message: 'username is already taken', path: '/username' },
)
const r = await validateAsync(Signup, body)
if (!r.valid) return reply.code(400).send(r.errors)
// or: resolves to the typed value, throws on failure with err.errors
const user = await parseAsync(Signup, body)Refinements compose by wrapping again, and a failing one surfaces as an error with keyword: 'refine' carrying your message and path. A check may be sync or async.
Validator<T> is generic, so if you already author schemas with a library, pass the type and ata narrows to it. No library-specific assumption.
import { Type, type Static } from '@sinclair/typebox'
import { Validator } from 'ata-validator'
const UserSchema = Type.Object({
id: Type.Integer({ minimum: 1 }),
name: Type.String({ minLength: 1 }),
})
const v = new Validator<Static<typeof UserSchema>>(UserSchema)The same works with Zod-from-JSON-Schema, Valibot, or a hand-written type User = {...} alongside a JSON Schema literal.
On a page, the schemas themselves can stay in the library and still compile away. @ata-project/unplugin 0.13.0 takes ata({ zod: 'src/events/*.ts' }) for modules that export zod 4 schemas and ata({ from: 'src/schemas/*.ts' }) for zod, valibot or TypeBox modules, and writes one compiled { validate, isValidObject } per export beside the module, typed with the library's own types, so the page carries neither the library nor a validator runtime. Measured on ata-validator 1.63.1, esbuild minified and gzipped: three zod 4.6.7 event schemas, 3.9 KB compiled against 93.0 KB with zod on the page; one TypeBox 0.34.52 schema, 1.1 KB against 25.5 KB with Value.Check; one valibot 1.5.0 schema, 1.1 KB against 1.7 KB. The details and what cannot be carried are under In the browser.
const addressSchema = {
$id: 'https://example.com/address',
type: 'object',
properties: { street: { type: 'string' }, city: { type: 'string' } },
required: ['street', 'city']
};
const v = new Validator({
type: 'object',
properties: {
name: { type: 'string' },
address: { $ref: 'https://example.com/address' }
}
}, { schemas: [addressSchema] });
// Or use addSchema()
const v2 = new Validator(mainSchema);
v2.addSchema(addressSchema);const v = new Validator(schema, {
coerceTypes: true, // "42" → 42 for integer fields
removeAdditional: true, // strip properties not in schema
schemas: [otherSchema], // cross-schema $ref registry
abortEarly: true, // skip detailed error collection on failure (~4x faster on invalid data)
engine: 'interpreter', // eval-free interpreted engine only: for a schema from outside the trust boundary
});abortEarly returns a shared { valid: false, errors: [{ message: 'validation failed' }] } on failure instead of running the detailed error collector. Useful when the caller only needs a pass/fail decision (Fastify route guards, high-throughput gatekeepers, request rejection at the edge).
engine: 'interpreter' keeps one validator off code generation: no new Function, no shared compile cache, the interpreted engine answers every call. The default turns a schema into JavaScript source, which is the right trade for a schema you wrote; a schema that arrives at runtime from a plugin or a tenant is input, and this option validates against it without ever executing anything derived from it. Same verdict, higher per-call cost; ATA_FORCE_NAPI=1 does the same for a whole process.
The ata CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on ata-validator, so only the generated validator ships to the browser. For the ten-field user schema in tests/fixtures/error-dx/user.schema.json the module is 2.1 KB gzipped, full error detail included, against 123.9 KB for the runtime bundled for the browser (ata 1.51.0).
npx ata compile schemas/user.json -o src/generated/user.validator.mjsThe CLI emits two files: the validator itself and a paired .d.mts (or .d.cts) with the inferred TypeScript type plus an isValid type predicate.
import { isValid, validate, type User } from './user.validator.mjs'
const incoming: unknown = JSON.parse(req.body)
if (isValid(incoming)) {
// TypeScript narrows to User here
incoming.id // number
incoming.role // 'admin' | 'user' | 'guest' | undefined
}
const r = validate(incoming)
// { valid: true, errors: [] } | { valid: false, errors: ValidationError[] }CLI options:
| Flag | Default | Description |
|---|---|---|
-o, --output <file> |
<schema>.validator.mjs |
Output path |
-f, --format <fmt> |
esm |
esm or cjs |
--name <TypeName> |
from filename | Root type name in the .d.ts |
--abort-early |
off | Generate the stub-error variant, about a third of the source |
--order |
off | Hand errors out in the runtime's order (schema declaration order), not the order the collector found them in |
--no-types |
off | Skip the .d.mts / .d.cts output |
A module answers validate() as the runtime does, with two differences a caller reading the module directly should know. The runtime fills in default values before it checks (useDefaults is on unless turned off), and the module does not: where the schema declares a default the module exports applyDefaults(data), the runtime's own pass, to call first. And the module returns its errors in the order its collector found them, which can put a field's format error before its pattern error, or behind a $ref a definition's required before its properties; --order sorts them as the runtime does. It is off by default because it costs bytes: 225 more gzipped for an eight-field form, about 1 KB for a small schema with references, whose key skeleton the module then carries, and 12.6% over 267 SchemaStore schemas. With both, a module matched the runtime's verdict, filled data and error list, in order, on every case of the official suites it compiles (1,500 groups, 5,324 cases across six dialects) and on 1,952 of SchemaStore's sample documents. fromCompiled() and modules that share the error runtime sort already and need neither.
For a project with many schemas, ata build <glob> compiles them all in one command:
npx ata build 'schemas/*.json' --out-dir build/validators --checkRun with --watch during development for incremental rebuilds.
In a production build (--no-source, or NODE_ENV=production), ata build can leave the
error collector out of the modules: each one carries its verdict and its schema, and takes
its errors from one ata-errors.mjs the build writes beside them. The build does this only
where it makes the output smaller, so a single small schema keeps its self-contained module.
The errors are the ones new Validator(schema, { richErrors: false }) reports, in the same
order. Forty SchemaStore schemas imported into one app, minified with esbuild, go from 571.7 KB
to 224.9 KB gzipped, and from 61.5 ms to 29.5 ms to import with code generation blocked. A
rejected document costs more this way, since its errors come from an interpreted walk:
--errors inline keeps every module self-contained. --mode schema writes each module as
its schema and takes the verdict from the runtime too: forty SchemaStore schemas in one app
come to 104.4 KB gzipped against 224.9 KB as compact modules, and load and check each once in
23.4 ms against 35.8, while a check once warm costs about five times what generated code costs.
These are ata 1.54.0 figures; 1.51.0 measured 513.8, 213.3 and 102.4 KB, and the growth is the
RFC 3986 walk that every module with a uri or uri-reference field now carries.
Schemas that reference each other by $id are built against each other, and in schema mode
they are written once to a shared registry that keeps only the definitions the modules reach;
--base-uri <uri> names files that declare no $id. Eight Kubernetes kinds, whose schemas
share one 1.1 MB definitions file, come to 32.6 KB gzipped as schema modules against 68.6 KB
self-contained, on ata 1.51.0.
Bundle sizes for the 10-field user schema in tests/fixtures/error-dx/user.schema.json,
minified and gzipped, measured with bun build --minify --target=browser on ata 1.54.0:
| What the app imports | Size | Notes |
|---|---|---|
Validator from ata-validator |
131.1 KB | The compiler ships with it, because a runtime schema can use any keyword |
isValid from the compiled module |
1.4 KB | Nothing else is reachable, so the error collector is dropped |
validate from the compiled module |
2.0 KB | Adds the detailed error collector |
--abort-early makes the generated source about three times smaller, and after
bundling it makes no difference: importing only isValid already leaves the error
collector unreachable, and a bundler drops it. Use the flag to cut the file on disk,
not to cut what ships.
To get the compiled module without changing code written against the runtime API, use
compileAway in @ata-project/unplugin: a
new Validator(schema) whose schema is known at build time is replaced with the compiled
module wrapped by fromCompiled() from ata-validator/compiled, which answers validate(),
isValidObject(), validateJSON() and isValidJSON() as the runtime does, defaults and errors
included. For the plugin's three-schema test entry a minified Vite build goes from 122.5 KB to
16.7 KB gzipped on ata 1.39.2. In Node, loading the wrapper and a compiled module and answering the first two
checks takes 1.12 ms where the runtime takes 6.63 ms (median of 15 fresh processes). Across
SchemaStore's 977 schemas, 725 can be compiled away; the rest stay on the runtime. Where the code only calls isValidObject() or isValidJSON(), the plugin uses
fromCompiledVerdict() from ata-validator/compiled-verdict instead, which carries no error pipeline: a
small app that only asks for a boolean bundles to 2.6 KB gzipped, against 12.1 KB with the full wrapper, on
ata 1.40.0 with @ata-project/unplugin 0.5.0. Where it reads errors but never validates JSON text, the plugin
uses fromCompiledObject() from ata-validator/compiled-object, the full wrapper without
validateJSON() and isValidJSON() and the position map behind them; and the wrappers fill
defaults with the compiled module's own applyDefaults. A form app with one validator that reads
errors and one that asks for a boolean went from 16.7 to 13.9 KB gzipped (esbuild, minified),
ata 1.61.0 against master with @ata-project/unplugin 0.11.0 and the change. The wrappers on
their own: full 13.8 to 12.8 KB, without JSON entry points 10.9, verdict only 2.7 to 1.6.
A validator built with { richErrors: false } is compiled away as well: the module writes its
errors in that plain shape (compiledModuleFor(schema, { plainErrors: true })) and
fromCompiledPlain() from ata-validator/compiled-plain hands them out as they are, with no
error pipeline in the bundle, 2.6 KB gzipped for the wrapper. Where the plugin sees a
validator only ever called (v.validate(x), v.isValidObject(x)), never handed to other code,
it uses the bare wrappers of ata-validator/compiled-bare instead, which leave out Standard
Schema and the extension hook nothing can reach there: 0.3 KB gzipped for both, and
ata-validator/compiled-bare-rich for one that reads ata's own errors. The same form app
bundles to 10.7 KB gzipped, or 3.1 KB with richErrors: false on the validator that reads errors.
A validator handed only to a Standard Schema consumer the plugin knows (Hono's
sValidator, hono-openapi, TanStack Form, oRPC, tRPC, Elysia) takes ata-validator/compiled-standard, which
carries ~standard alone on a module with plain errors: a Hono route on Cloudflare Workers goes
from 21.0 to 12.2 KB gzipped, 4.4 KB over Hono by itself. The errors
then carry keyword, instancePath, schemaPath, params and message, as that option has
always given them, without received, suggestion or docUrl.
Programmatic API if you prefer to script it:
const fs = require('fs');
const { toStandaloneModule } = require('ata-validator/build');
fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));Custom format functions either get their source embedded (the default, refused
at build time with a named error when the function would not survive
serialization) or, with formatMode: 'inject', are supplied at load time
through a setFormats() export the module carries. docs/API.md has the
details.
Fastify startup, 10 route schemas, from a cold process to the first validated request:
the default validator 27.2 ms, ata 9.0 ms, no build step required (Node 25.2.1, ata-validator
1.63.1, 2026-11-06, loading each library included, the median of 7 fresh processes, three runs
agreeing). ata loads in 3.1 ms, registers the routes in 0.24 ms and compiles on the first
request, 5.6 ms, so counting only registration would overstate the gap; the default validator's
split is 8.5 ms to load, 17.6 ms to set up and 1.1 ms for the first request. The first request
here comes in the same tick as setup; a server that listens first has the code generator
loaded by then. Reproduce with node benchmark/bench_fastify_boot.mjs.
A server that listens first also gets the engine warmed: once the event loop is free, if a
validator was built and not yet called, ata runs one check of a small schema, accepted and
rejected, so that V8 compiles the generator's functions and the error builders then rather
than on the first request. Measured on a four-field schema with the generator already
loaded: the first verdict 3.1 to 3.4 ms without it, 0.5 to 0.6 with it; the first error
list 6.7 against 0.8; a second route's first verdict 0.9 to 1.0 against 0.7 to 0.9. It costs
the process 1.5 to 2 ms of CPU once. A script that builds a validator and checks in the
same tick never runs it, and the timer is unref'd, so a finished script does not stay for
it; ATA_NO_WARM=1 turns it off.
A module written at build time is only a good idea if it answers as the
runtime it replaced. ata verify checks that on the project's own schemas:
npx ata verify 'schemas/*.json' --samples test/fixturesFor each schema it loads the module ata build wrote beside it, makes
documents from the schema (200 by default, half of them with one rule broken,
then a document on either side of each bound the schema states),
reads the project's sample documents (JSON files, JSON Lines, or a directory
of them), and has both the module and a Validator built from the schema now
answer every one. Verdicts and error lists have to agree; a module whose
schema hash no longer matches the schema is reported stale. The exit code is
non-zero on any disagreement, stale module or missing module, so the command
works as a CI step. This is the check that found the two silent divergences
fixed in 1.59.0 and 1.60.0, now in a form a project can run on every change.
It does not run the .bun.ts target, which needs Bun's compiler.
Reading two versions of a schema side by side says which keywords changed. It
does not say which documents change verdict, and that is the question before
a deploy: which requests that pass today get a 400 tomorrow. ata diff answers
it with documents:
npx ata diff schemas/user.json schemas/user-v2.json --samples test/fixtures401 documents: 102 accepted by both, 293 rejected by both, 6 rejected only by user-v2.json, 0 accepted only by user-v2.json
Rejected by user-v2.json, accepted by user.json (a client sending these gets a rejection after the change):
minimum@/age: 4 documents, 1 of them the project's own, must be >= 18
{"email":"kid@example.com","name":"Kid","age":15} (test/fixtures/signup-teen.json)
maxLength@/name: 2 documents, must NOT have more than 40 characters
{"email":"a@b.co","name":"abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij...
The documents are the ones ata verify makes, from both schemas (200 each by
default, half with one rule broken, then a value on either side of every
bound), plus the project's own samples. Each one is checked by a Validator
built from each schema, and the ones that change verdict are grouped by the
rule that decides them, with one example each, the project's own document
when there is one. The exit code is 1 when a document the old schema accepts
is rejected by the new one, so a pull request that tightens a schema fails
the check until someone looks. Generated documents are a sample, not a proof:
a change this reports is real, one it does not report may still exist.
A request that carries many documents, a JSON array or NDJSON, is usually read to the end,
parsed as a whole and then validated, so a bad third record is reported after the last byte.
ata-validator/stream validates each record the moment its last byte lands and stops reading
at the first failure, with memory that does not grow with the body.
import { validateStream } from 'ata-validator/stream'
app.post('/orders/import', async (c) => {
const r = await validateStream(orders, c.req.raw.body) // a Web ReadableStream, or a Node Readable
if (!r.valid) return c.json({ record: r.errors[0].record, errors: r.errors[0].errors }, 400)
return c.json({ imported: r.records })
})v.stream(opts) on Node, or streamValidator(v, opts) from ata-validator/stream anywhere (the lite, edge and browser entries do not carry the method, so their bundles stay what they were), is the same thing fed by hand: push(chunk) takes strings or bytes and returns
the first failure as soon as its record is complete, end() returns { valid, records, errors }.
onRecord(doc, index) hands over each record that passed, parsed, so an import does not parse
twice; stopOnFirst: false lists every failing record instead of stopping. The format is read
from the first character ([ is an array, anything else NDJSON) or set with format. A JSON
array is split by a scan that tracks strings and nesting, not by parsing it, so the elements are
parsed one at a time.
Measured on a 100 MB body of 128,419 order documents, read in 64 KB chunks, Node 25, Apple M4 Pro, one process per row:
| invalid record 3 | invalid last record | |
|---|---|---|
read it all, JSON.parse, validate the array |
517 ms, 783 MB | 571 ms, 845 MB |
| read it all, split lines, parse and validate each | 237 ms, 553 MB | 462 ms, 660 MB |
ata-validator/stream, JSON array |
13.5 ms, 122 MB, 0.1 MB read | 577 ms, 195 MB |
ata-validator/stream, NDJSON |
19.6 ms, 122 MB, 0.1 MB read | 461 ms, 197 MB |
Peak RSS; the 100 MB figure is the process without the body. A body that is valid to the end
costs the same time as before and a third of the memory. tests/test_stream.js holds the stream
to whole-document validation under every way of cutting the bytes into chunks, including inside
multi-byte characters, strings with brackets and escaped quotes, nested arrays, scalars, CRLF
and blank lines, and checks that a failing source is destroyed or cancelled rather than read on.
A Node http request handed to validateStream is not destroyed at the failure: closing the
socket under the response turns the answer into a network error at the client (Node's fetch
uploading 100 MB got write EPIPE, Chrome "Failed to fetch", neither the 400). Reading stops and
the rest of the body is read and dropped, so the response goes out and memory stays flat. Node's
fetch then had the 400 after 24 ms and 1 MB sent; Chrome, which reads only after its upload,
after 58 ms for a 50 MB file over loopback; the Hono example above, whose Node adapter cancels
the stream without closing the socket, after 28 ms. A plain Readable is still destroyed.
Bun's ahead-of-time compiler (bun build --compile --aot, an open pull
request as of October 2026) runs code that a library hands to new Function
through an interpreter. Measured with its pull request build on one schema
and 1024 parsed documents, nanoseconds per document (benchmark/proof/aot):
| JIT | ahead of time | |
|---|---|---|
a module from ata build --target bun |
25 | 24 |
new Validator(schema), generated code |
28 | 492 |
new Validator(schema), interpreted engine |
52 | 80 |
So, in order of preference:
-
Compile the schema at build time.
@ata-project/unplugin/bundoes it inBun.build, includingBun.build({ compile });ata buildwrites the module by hand. The module is plain JavaScript and compiles with the program: 24 ns, the same errors as the runtime. -
Where the runtime
Validatorstays, let it take the interpreted engine. It does so by itself underbun build --compile --aotandbun run --aot, where Bun resolves the package's"aot"export condition to an entry that says so; also when the executable is built with--disallow-code-generation-from-strings(the program then counts as closed for the compiler, too), or whenATA_ENGINE=interpreteris set: 78 ns against 487 for the generated code there. -
Write the module for the compiler.
ata build schema.json --target bunwrites it with a closed object's keys read once and compared with the declared list in order (on a ten-item order with 1024 distinct documents, 10% of them invalid, 105 to 107 ns under the compiler against 223 for the form V8 gets; on V8 the module keeps for...in, on Bun's JIT a plain count, each the cheaper form there), and answers a rejection with the one-pass error function (189 ns with the error list on that order, against 724 with the older collector), the form every other build takes only with--one-passsince it is 20% larger gzipped. Beside the module it writesschema.compiled.bun.ts: the schema as a TypeScript type (kinds, properties and which are required, items and fixed tuples, literals, local definitions as named types),validateandisValidwith that type on them, andparse(text), which declares the type where it parses and has the module check the content. Every check is the module's, and the file is a type for the program, not a faster path: on that order, with the pull request build of 2026-10-25,isValid118 ns through the file against 118 to 121 through the module,parse564 to 574 against 570 to 578 for a plain parse and the module, and reading the parsed object after a typed parse 593 to 595 against 587 to 591 after a plain one. The file is for executables built with--aot; a project points that compiler at it with an"aot"condition in itspackage.jsonimports, which nothing else reads:{ "imports": { "#schemas/*": { "aot": "./schemas/*.bun.ts", "default": "./schemas/*.js" } } }A schema no type can be written for (a remote
$ref,$dynamicRef, a root the type says nothing about) gets a warning and the plain module alone.
The pull request carried its own typed validator until 2026-10-25, and
--target bun wrote the schema's rules as a type for it; it left the pull
request to return as a change of its own. docs/API.md has what ata keeps
for it. A schema's pattern is a new RegExp at run time in the runtime
Validator, which that compiler runs through its regular expression
interpreter; the module writes each pattern as a literal, which it compiles.
--aot makes TypeScript errors fatal; ata's declarations say message?: string on an error, so read it as such. The numbers are from the pull
request build of 2026-10-24 on one machine and will be run again when the
compiler ships.
const v = new Validator(schema);
// Works with Fastify, tRPC, TanStack, etc.
const result = v['~standard'].validate(data);
// { value: data } on success
// { issues: [{ message, path }] } on failureMeasured against Fastify's own schema and validation test files with ata swapped in as the default validator: 178 of 184 tests pass, and the remaining six assert the default validator's own extension API rather than validation behavior. Validation errors follow the schema's keyword declaration order, so error-message plugins and transformErrors hooks written against the default validator see the same order.
npm install fastify-ataconst fastify = require('fastify')();
fastify.register(require('fastify-ata'), {
coerceTypes: true,
removeAdditional: true,
});
// All existing JSON Schema route definitions work as-isfastify-ata/stream (since fastify-ata 0.11.0) is a content-type parser that validates a JSON array body record by record as it arrives and ends the request at the first invalid record, so a bad import does not have to be read whole. Measured on a 100 MB body invalid at record 50: 564 ms and +569 MB of memory before, 11 ms and +2 MB after.
fastify-ata/plans (since 0.12.0, needs ata-validator ^1.63.1) serves plans to pages. app.register(require('fastify-ata/plans'), { plans: { signup: signupSchema } }) writes each plan once at startup with tablePlan and serves it at GET /plans/<name> with an ETag, max-age=300, 304 on a matching If-None-Match and 404 for an unknown name; fastify.ataPlan(name) is for handlers that embed it. The page reads it with fromPlan from ata-validator/plan, 6.2 KB gzipped on 1.63.1, and gets the same verdicts, errors and filled defaults as new Validator(schema) on the same ata version. A schema a plan cannot carry fails registration by name; on 1.63.1 that includes anyOf, oneOf, patternProperties, propertyNames, contains, dependentSchemas and propertyDependencies, which the next ata release carries.
#include "ata.h"
auto schema = ata::compile(R"({
"type": "object",
"properties": { "name": {"type": "string"} },
"required": ["name"]
})");
auto result = ata::validate(schema, R"({"name": "Mert"})");
// result.valid == trueRegister keywords the schema vocabulary does not have with the keywords option. A definition is a validate function, a compile factory, or a macro that returns a schema, with an optional type that limits which values it sees.
const v = new Validator(
{ properties: { title: { type: 'string', maxWords: 5 } } },
{
keywords: {
maxWords: {
type: 'string',
compile: (n) => (s) => s.split(/\s+/).length <= n,
},
},
},
)
v.validate({ title: 'one two three four five six' })
// { valid: false, errors: [{ keyword: 'maxWords', instancePath: '/title', ... }] }A schema that uses a custom keyword runs on the interpreted engine, where anyOf, not and $ref keep their meaning around the custom check, and it cannot be compiled ahead of time, since a standalone module imports nothing and cannot carry the function. bundleStandalone refuses such schemas rather than emitting a module that would ignore the keyword. See docs/custom-keywords.md.
Copy-paste recipes for the common frameworks. Most need 10-20 lines of glue. See docs/integrations for the full set.
| Framework | Pattern | Recipe |
|---|---|---|
| Fastify | dedicated plugin; fastify-ata/stream validates an array body as it arrives, fastify-ata/plans serves plans to pages |
fastify-ata |
| Vite, Webpack, Rollup, Rolldown, esbuild, Rspack, Bun (build-time compile) | dedicated plugin; the zod and from options compile zod, valibot and TypeBox modules at build time |
@ata-project/unplugin |
| Vite (build-time compile) | dedicated plugin | ata-vite, the Vite entry of the plugin above, with the same zod and from options |
| Zod (runtime) | zod schemas run on the ata engine with zod's answers | @ata-project/zod |
| Valibot (runtime) | valibot schemas run on the ata engine with the same answers | @ata-project/valibot |
| MCP TypeScript SDK | tool schema validation where code generation is blocked | @ata-project/mcp |
| Next.js (build-time compile, Turbopack and webpack) | config wrapper of the plugin above | withAta from @ata-project/unplugin/next in next.config.mjs |
| Hono | async middleware | docs/integrations/hono.md |
| Elysia | Standard Schema V1 in the body slot |
docs/integrations/elysia.md |
| tRPC | Standard Schema V1 input | docs/integrations/trpc.md |
| TanStack Form | Standard Schema V1 validator | docs/integrations/tanstack-form.md |
| React Hook Form | resolver | @hookform/resolvers/ata-validator |
| Express | sync middleware | docs/integrations/express.md |
| Koa | async ctx middleware | docs/integrations/koa.md |
| NestJS | validation pipe | docs/integrations/nestjs.md |
| SvelteKit | form action, API route | docs/integrations/sveltekit.md |
| Astro | API route, server action | docs/integrations/astro.md |
| Category | Keywords |
|---|---|
| Type | type |
| Numeric | minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| String | minLength, maxLength, pattern, format |
| Array | items, prefixItems, minItems, maxItems, uniqueItems, contains, minContains, maxContains, unevaluatedItems |
| Object | properties, required, additionalProperties, patternProperties, minProperties, maxProperties, propertyNames, dependentRequired, dependentSchemas, unevaluatedProperties |
| Enum/Const | enum, const |
| Composition | allOf, anyOf, oneOf, not |
| Conditional | if, then, else |
| References | $ref, $defs, definitions, $id, $anchor, $dynamicRef, $dynamicAnchor |
| Boolean | true, false |
| v1 | propertyDependencies |
$schema selects the dialect. Draft 2020-12 is the default; draft-07, draft-06, draft-04 and Draft 2019-09 are rewritten to the 2020-12 spelling on the way in (draft-06 as draft-07 without if/then/else, draft-04 as draft-06 with id for $id and without const, contains and propertyNames, 2019-09's tuple items and $recursiveRef as prefixItems and $dynamicRef); and https://json-schema.org/v1 (or the dated https://json-schema.org/v1/2026) selects JSON Schema v1. The official suite passes in full on all six: 1302 of 1302 on 2020-12, 1261 of 1261 on 2019-09, 929 of 929 on draft-07, 841 of 841 on draft-06, 618 of 618 on draft-04 and 1135 of 1135 on v1, with code generation allowed and blocked. Draft-03 and earlier are not supported; draft-04's boolean exclusiveMinimum/exclusiveMaximum is read wherever it appears.
Two things differ under v1. propertyDependencies selects a subschema by the value of a property rather than by its presence, which is what dependentSchemas does. And $dynamicRef no longer requires bookending: the reference resolves through the dynamic scope whether or not the schema it first lands on carries a matching $dynamicAnchor, so the outermost matching anchor still in scope wins. Everything else ata implements is identical under both dialects, so a schema that declares no $schema behaves exactly as before.
Both are implemented in the interpreted engine, so a v1 schema that uses $dynamicRef validates there rather than through the compiler or the native addon, which resolve the 2020-12 way. Schemas that use neither keyword take the same compiled path they always did.
email, date, date-time, time, uri, uri-reference, ipv4, ipv6, uuid, hostname
Running the whole Draft 2020-12 suite with nothing excluded, format and default under specification semantics (assertFormat: false, useDefaults: false), gives 1302 of 1302 cases. Draft 2019-09 gives 1261 of 1261, draft 7 929 of 929, draft-06 841 of 841, draft-04 618 of 618. The v1 dialect gives 1135 of 1135. npm run test:suite reproduces all six.
Areas that remain deliberate scope decisions for 1.x:
- Remote
$refover the network is not fetched. Register cross-schema refs explicitly withschemas: [...]oraddSchema(). $vocabularyis honoured for the document which names the meta-schema: a keyword whose vocabulary that meta-schema does not declare is not part of the dialect, so it is treated as unknown and does not apply. Two things it does not do. It does not refuse a schema whose meta-schema requires a vocabulary ata does not recognise, which the specification says an implementation must do; that schema is evaluated with every keyword applied, as before. And a separate document reached through$refkeeps its own keywords rather than inheriting the referring dialect, so register it with its own$schemaif it should follow one.contentEncoding/contentMediaType/contentSchemaare annotation-only, as the spec permits, and are not validated.minLength/maxLengthcount UTF-16 code units, not grapheme clusters.- Infinite-loop detection relies on a recursion depth guard that cuts off circular
$refchains. defaultvalues are applied to validated data by default, where the spec treatsdefaultas a non-enforcing annotation. PassuseDefaults: falsefor the specification behavior.formatis asserted by default rather than treated as an annotation. PassassertFormat: falsefor the specification behavior.
If one of these blocks you, open an issue; scope decisions get revisited with real use cases.
This section applies to contributors building the repository. Regular npm install users need not have a C++ toolchain.
Native builds require C/C++ toolchain support and the following libraries:
re2abseilmimalloc
Install them before running npm run build:
# macOS (Homebrew)
brew install re2 abseil mimalloc# Ubuntu/Debian (apt)
sudo apt-get update
sudo apt-get install -y libre2-dev libabsl-dev libmimalloc-dev# C++ library + tests
cmake -B build
cmake --build build
./build/ata_tests
# Node.js addon
npm install
npm run build
npm test
# JSON Schema Test Suite
npm run test:suite- CHANGELOG.md records every release. It is kept in the repository and not shipped in the npm package, where its history had grown to about 13% of the install.
- CONTRIBUTING.md explains how to build the project and what a pull request needs before it can be merged.
- GOVERNANCE.md says who decides what, which changes the project will not accept and why, how releases are cut, and how someone becomes a maintainer. It states the bus factor plainly rather than leaving you to find it out.
- SECURITY.md is where vulnerabilities go. Not the issue tracker.
- CODE_OF_CONDUCT.md applies everywhere the project is discussed.
MIT
@mertcanaltin · @SukeshP1995 · @lemire · @armagandalkiran · @pnodet
Missing from this list after landing a patch? Send a pull request.
