Skip to content

About

JSON Schema validator that compiles to plain JavaScript, still runs where code generation is blocked, and can be compiled away at build time. Passes the full official test suite. https://socket.dev/npm/package/ata-validator

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

392 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,434 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ata-validator

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.

npm License OpenSSF Scorecard OpenSSF Best Practices Socket Badge

1.0 is a stability commitment: see docs/STABILITY.md for the semver, deprecation, and error-code guarantees.

The type and the schema

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.

Who uses ata

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-ata plugin 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.

Quick start

npm install --save-dev ata-validator
npx ata build 'schemas/*.json' --out-dir src/generated

The 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=optional

or 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.

In the browser

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.

Measured by others

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 uri format 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.

Side by side

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 on one order body, eleven validators

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.

Why AOT

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.

Schemas and models

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.

Error messages

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>.

Custom messages

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.

Opting out

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.

Which path, and where

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.

When to use the runtime API instead

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 rest

Coercion, defaults, allErrors and the error objects behave as before; the details and the limits are in docs/migration-from-ajv.md.

Usage

Node.js

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);        // number

Type-safe schemas

ata 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.

Extracting the type: Infer

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.

Chainable authoring with ata-validator/t

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 }

Async refinement

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.

Composes with TypeBox, Zod, or your own types

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.

Cross-Schema $ref

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);

Options

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.

Build-time compile (ata compile)

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.mjs

The 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 --check

Run 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.

Proving the build (ata verify)

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/fixtures

For 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.

What a schema change does (ata diff)

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/fixtures
401 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.

Validation as the body arrives

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, compiled ahead of time

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:

  1. Compile the schema at build time. @ata-project/unplugin/bun does it in Bun.build, including Bun.build({ compile }); ata build writes the module by hand. The module is plain JavaScript and compiles with the program: 24 ns, the same errors as the runtime.

  2. Where the runtime Validator stays, let it take the interpreted engine. It does so by itself under bun build --compile --aot and bun 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 when ATA_ENGINE=interpreter is set: 78 ns against 487 for the generated code there.

  3. Write the module for the compiler. ata build schema.json --target bun writes 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-pass since it is 20% larger gzipped. Beside the module it writes schema.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), validate and isValid with that type on them, and parse(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, isValid 118 ns through the file against 118 to 121 through the module, parse 564 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 its package.json imports, 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.

Standard Schema V1

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 failure

Fastify Plugin

Measured 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-ata
const fastify = require('fastify')();
fastify.register(require('fastify-ata'), {
  coerceTypes: true,
  removeAdditional: true,
});

// All existing JSON Schema route definitions work as-is

fastify-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.

C++

#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 == true

Custom keywords

Register 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.

Framework integrations

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

Supported Keywords

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

Dialects

$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.

Format Validators (hand-written, no regex)

email, date, date-time, time, uri, uri-reference, ipv4, ipv6, uuid, hostname

Known limitations

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 $ref over the network is not fetched. Register cross-schema refs explicitly with schemas: [...] or addSchema().
  • $vocabulary is 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 $ref keeps its own keywords rather than inheriting the referring dialect, so register it with its own $schema if it should follow one.
  • contentEncoding / contentMediaType / contentSchema are annotation-only, as the spec permits, and are not validated.
  • minLength/maxLength count UTF-16 code units, not grapheme clusters.
  • Infinite-loop detection relies on a recursion depth guard that cuts off circular $ref chains.
  • default values are applied to validated data by default, where the spec treats default as a non-enforcing annotation. Pass useDefaults: false for the specification behavior.
  • format is asserted by default rather than treated as an annotation. Pass assertFormat: false for the specification behavior.

If one of these blocks you, open an issue; scope decisions get revisited with real use cases.

Building from Source

This section applies to contributors building the repository. Regular npm install users need not have a C++ toolchain.

Development prerequisites

Native builds require C/C++ toolchain support and the following libraries:

  • re2
  • abseil
  • mimalloc

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

Project

  • 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.

License

MIT

Contributors

@mertcanaltin · @SukeshP1995 · @lemire · @armagandalkiran · @pnodet

Missing from this list after landing a patch? Send a pull request.

About

JSON Schema validator that compiles to plain JavaScript, still runs where code generation is blocked, and can be compiled away at build time. Passes the full official test suite. https://socket.dev/npm/package/ata-validator

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

392 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages