Programmatic API for generating predicate-style TypeScript validators from JSON Schemas.
@amritk/generate-validators produces lightweight runtime validators from a JSON Schema. Where @amritk/generate-parsers coerces and parses unknown input into a typed value, this package emits cheaper predicate-style functions that simply tell you whether a value matches a schema (and where it doesn't).
Each generated file exports:
- A TypeScript
typedefinition for the schema - A
validateFoo(input: unknown, _path?: string): ValidationResultfunction - An
isFoo(input: unknown): input is Fooboolean type guard — a single flat predicate (no error array, no cold-path call) reaching the same verdict asvalidateFoo, for the common "is this valid?" question
A shared validation-result.ts template and an index.ts barrel are emitted alongside the generated files.
npm install @amritk/generate-validators
# or
pnpm add @amritk/generate-validators
# or
yarn add @amritk/generate-validators
# or
bun add @amritk/generate-validatorsimport { buildValidatorSchema } from '@amritk/generate-validators'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
const schema: JSONSchema = {
type: 'object',
properties: {
info: { $ref: '#/$defs/info' },
},
$defs: {
info: {
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
},
},
}
const files = await buildValidatorSchema(schema, 'Document')
// → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, { filename: 'validation-result.ts', ... }, { filename: 'index.ts', ... }]Write the resulting files to disk and import the validators where you need them:
import { validateDocument } from './generated'
const result = validateDocument(input)
if (!result.valid) {
console.error(result.errors)
}| Parameter | Type | Default | Description |
|---|---|---|---|
rootSchema |
JSONSchema |
— | The root schema to traverse. $ref and $dynamicRef are resolved recursively. Draft-07 schemas are upgraded to 2020-12 automatically. |
rootTypeName |
string |
— | Name used for the root type (e.g. "Document"). |
typeSuffix |
string |
'' |
Suffix appended to every $ref-derived type name ('Object' turns Contact into ContactObject). The root type name is unaffected. |
schemas |
Record<string, unknown> |
— | Documents you have already loaded, keyed by the absolute URI a $ref names them by. See below. |
Returns: Promise<GeneratedFile[]> where GeneratedFile = { filename: string; content: string }.
A $ref to a URI is resolvable once you hand over the document behind it:
const files = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
'https://example.com/user.json': userSchema,
})Each registered document becomes a resource of the generated document: its $id,
its $anchors and $dynamicAnchors and its own embedded resources all resolve, a
$ref from one registered document into another resolves, and each definition
reached gets a file and a type like any other. A document with no $id resolves
its relative $refs against the URI you registered it under; one whose $id
disagrees answers to both.
Nothing is fetched — you cannot pass a URL, only a document — so generation stays
a pure function of its inputs. Loading is yours to do, or
@amritk/resolve-refs'. Registering more than the schema uses
costs nothing: only the documents actually reached are emitted. A $ref to a URI
nobody registered still stops the build, with a message naming the ref.
Generated validators track the @amritk/runtime-validators interpreter. Array
items are validated in full — an item's type, $ref, nested properties /
required, and scalar constraints (minLength, minimum, …) are all enforced,
recursing to any depth — and the boolean guard (isX) reaches the identical
verdict. Validating array item contents costs throughput proportional to the
per-item work (a bare string[] is free; a closed object with several fields is
meaningfully slower), which is why array-heavy schemas validate more slowly than
scalar/object ones.
A schema needs no type for its keywords to be enforced. { minLength: 2 },
{ required: ['a'] } and { uniqueItems: true } each emit their check behind a
runtime test for the family they constrain, so they reject a bad string / object /
array and ignore every other kind of value — which is what JSON Schema means by
a type-less constraint, and what the interpreter does. One consequence is worth
knowing: object keywords no longer imply type: 'object', so validateX accepts
42 against { properties: { … } }, while the emitted TypeScript type still
describes the object case (as FromSchema does in @amritk/runtime-validators).
The verdict is the contract and it matches the interpreter exactly; for that one
shape isX is a weaker type guard than the type it names. Declare a type — as
almost every real schema does — and the guard is exact again.
format emits no check. JSON Schema treats format as an annotation, and so
does this generator: { type: 'string', format: 'uuid' } produces the typeof
check and nothing more. That matches the interpreter's default, but not the
interpreter run with { formats: 'all' } — as @amritk/lint and
createApi({ formats }) do — so a generated validator accepts strings those
reject.
unevaluatedProperties / unevaluatedItems are generated, not refused. Each
emits a flat expression computing what the interpreter computes as annotations: per
key or index, a boolean that is true when some keyword evaluated it. Keywords that
must succeed for the value to be valid at all (allOf members, a $ref target, a
satisfied contains) count unconditionally — sound, because the test is one
conjunct of a validator that also asserts them — while conditional applicators
(anyOf / oneOf branches, if / then / else, dependentSchemas) carry their
condition, hoisted out of the per-key loop. Four shapes still refuse, each named as
a shape rather than as a keyword: coverage running through a $dynamicRef, an
unresolvable or cyclic $ref at the same instance location, a walk deeper than
eight applicators, and a node under an inert additionalItems — one with no
array items to be the tail of, or with a prefixItems that took the positions
out from under it. The draft-07 tail itself is validated, so the draft-07
spelling of a schema whose 2020-12 spelling generates is accepted too.
One edge worth calling out: NaN fails a constrained number but satisfies an
unconstrained one. Every bound is emitted as the negated pass condition
(!(x >= minimum), not x < minimum), and NaN compares false against every
operator, so it fails the bound — and multipleOf rejects it on both branches of
the shared @amritk/helpers/multiple-of-check. A bare { type: 'number' } with
no constraint still accepts it, as Ajv does. This matches
@amritk/runtime-validators exactly, and the match is pinned value-by-value in
interpreter-parity.test.ts rather than asserted here. NaN never appears in
parsed JSON, so this only matters for values built in memory.
The semantics above are measured, not asserted.
src/generators/conformance.test.ts generates a validator for each schema in the
official JSON Schema Test Suite
(the required Draft 2020-12 tests — 1281 cases), compiles and links the emitted
files in memory, and runs the suite's instances through the real generated code:
1274 / 1281 cases pass (99.5%).
The suite's remotes/ documents and the 2020-12 dialect metaschema are supplied
through the schemas option, which is how the suite intends a validator that does
no I/O to answer the retrieval step. Everything else — applying the base URIs,
walking anchors across documents, naming and emitting a file per definition — the
generator still has to do.
Of the 7 that do not pass: four $dynamicRefs whose binding depends on the
evaluation path (a generator emits one function per definition, shared by every
path that reaches it, so it cannot bind per path), two definitions in different
embedded resources that reduce to one filename, and $vocabulary. Nothing on the
list is a keyword that silently returns the wrong answer.
Every case is named in
src/generators/conformance-expected-failures.test-utils.ts with its reason, and
the test fails if a case moves in either direction — a regression breaks the
build, and so does a case that starts passing without its entry being removed.
If you need one of those refusals to be an answer instead, validate with
@amritk/runtime-validators, which passes the same corpus
in full (1281/1281), at the cost of interpreting the schema at runtime.
The corpus is vendored under
fixtures/json-schema-test-suite; none
of it is published.
Generated validators are straight-line, monomorphic TypeScript with no generic
dispatch. The exported validateX is split into a hot and a cold half: on the
happy path it runs a single allocation-free boolean guard — a pure && chain of
typeof checks (plus an Object.keys().length count when an object is closed
with additionalProperties: false) — and return trues straight away, only
calling a separate error-collecting function when something is actually wrong.
Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
check beats every other library measured — including the build-time transformer
typia — while still emitting full JSON-Pointer errors for invalid input, and
emitting the validator stays far cheaper than compiling a schema at startup.
Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
| schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
|---|---|---|---|---|---|
| small (4 fields) | ~49M ops/s | ~6.4M ops/s | ~11M ops/s | ~5.7M ops/s | ~2.4M ops/s |
| order (nested + array) | ~11M ops/s | ~2.5M ops/s | ~4M ops/s | ~2.4M ops/s | ~0.52M ops/s |
| assert-loose | ~177M ops/s | ~162M ops/s | ~46M ops/s | ~70M ops/s | ~3.9M ops/s |
| assert-strict | ~164M ops/s | ~146M ops/s | ~20M ops/s | ~44M ops/s | ~1.5M ops/s |
The assert-loose / assert-strict rows are the exact shape used by
moltar/typescript-runtime-type-benchmarks
(seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
typia on both, by ~10% on assert-loose and ~12% on assert-strict (with
additionalProperties: false) — close enough that the two can trade the lead
run-to-run. (typia and TypeBox still win the invalid path, where they bail on
the first error rather than collecting a full error list.)
Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.04–0.3 ms for a
TypeBox TypeCompiler compile, versus ~7–11 ms for an Ajv compile. Every library
agrees on every verdict; parity is asserted before timing.
One caveat on the first two rows: their schemas declare format (uuid,
email), and Ajv, typia, zod, and TypeBox all check it, while mjst's generated
validators treat it as an annotation (see Semantics). So on small
and order, mjst is doing slightly less work than the columns beside it — the
parity samples fail other constraints too, which is why the verdicts still agree.
The assert-loose / assert-strict rows carry no format and are the
constraint-for-constraint comparison. Each library is
timed in an isolated process over a pool of distinct inputs, reporting the median
of many trials — so the optimiser can't hoist or eliminate the work and the
numbers stay reproducible. Micro-benchmark figures vary by machine and runtime —
reproduce with:
bun run bench@amritk/generate-parsers— type definitions plus parsers that coerce input@amritk/mjst— CLI wrapper around the generators@amritk/helpers— shared schema-traversal utilities