@amritk/validation
One generator surface over mjst's types, guards, validators, coercers, repairers and parsers.
Overview
@amritk/validation is the front door. Given a JSON Schema (Draft 2020-12) it emits
one coherent set of TypeScript files carrying whichever runtime entry points you
ask for — from a bare type, through a boolean guard and a full error report, to a
coercer, a repairer and a total parser.
Underneath it are two generator engines, not one: a validator, coercer and
repairer engine, and a parser and type engine. They shipped separately as
@amritk/generate-validators and @amritk/generate-parsers until this package
absorbed them, and keeping both is deliberate. They emit genuinely different
code for the value-producing modes: a parser fuses building the output with
checking it, and is several times faster for it; a validator keeps the passes
apart, and can therefore report what was wrong. Collapsing them into one emitter
would mean giving up one of those properties.
What makes one output directory possible is that both derive the TypeScript type
from the same @amritk/helpers/generate-type-definition, so the type is declared
once and every function in the output is talking about it.
Installation
bun add @amritk/validation
Usage
import { generate } from '@amritk/validation'
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
const schema: JSONSchema = {
type: 'object',
properties: {
retries: { type: 'integer', minimum: 0, maximum: 10, default: 3 },
name: { type: 'string', minLength: 3 },
},
required: ['retries', 'name'],
}
const files = await generate(schema, 'Config', {
modes: ['types', 'guard', 'validate', 'coerce', 'repair', 'parse'],
})
for (const file of files) await Bun.write(`./generated/${file.filename}`, file.content)
The modes
Each mode is a different answer to how much may this do to my document, and what
do I get told. They compose: ask for several and you get several functions over
one type, and every one that judges a value judges it with the same validateX,
so no two can disagree about whether a document is valid.
| mode | function | value back | tells you | stops at |
|---|---|---|---|---|
types | — | — | — | — |
guard | isX(input): input is X | — | a boolean | the first problem |
validate | validateX(input) | — | every error | the end |
check | checkX(input) | — | the first error | the first problem |
coerce | coerceX(input) | coerced | every error | the end |
repair | repairX(input) | repaired | repairs and errors | the end |
parse | parseX(input) | repaired | nothing | never fails |
parseStrict | parseX(input) | as given | throws the first | the first problem |
Validate only, fail fast or find every error. guard is the fail-fast half:
it stops at the first thing wrong and builds no error object, which on invalid
input is dramatically cheaper than reporting. validate is the other half: every
error, each with a JSON Pointer, the keyword that rejected the value, and that
keyword's own values. Reach for guard when the answer is a branch and
validate when a human or an API client has to be told what to fix. check sits
between them: checkX returns the same result type as validateX, holding only
the error validateX would have reported first, and walks no further.
Coerce and repair, with or without the errors. coerceX and repairX hand
back the value and the diagnostics; parseX hands back only the value and
never fails. Coercion moves a value that is already right but written in the
wrong type; repair also substitutes a value the schema supplies for one that
cannot be coerced, and reports the validator's own errors as the repairs.
That makes repairX an autofix tool rather than a stricter validator. It accepts
documents validateX rejects (a missing required name gets one; an unknown
enum value becomes the first member), and accepting only valid with an empty
repairs gives exactly the verdicts coerceX gives. An empty repairs alone is
not enough: a document nothing could repair comes back with none either, and
valid: false. To reject a bad config, use coerceX. To hand back a fixed one,
use repairX.
Repairing large documents. repairX(input, options?) takes an options
object (a bare string is still read as the base path):
| option | default | what it does |
|---|---|---|
path | '' | JSON Pointer every reported path is rooted at |
inPlace | false | assign and delete on the input instead of copying the containers a repair touches |
coerce | true | move a scalar written in the wrong type ("5" under integer) before repairing; off, it is repaired to the schema's value and reported |
refs | true | follow instance $refs at positions whose Reference schema opts in (below) |
skipRefs | [] | pointer prefixes ('#/components/') whose targets the root schema already walks; still resolved, not walked again |
onRef | — | (value, path, typeId) => boolean | void, called for every opted-in $ref; false leaves it alone |
maxDepth | 512 | depth past which a position is reported (maxDepth) instead of walked |
maxWork | 10_000_000 | containers visited before the walk stops (maxWork) |
memo | false | walk each object once per schema — for in-memory graphs with shared or cyclic subtrees |
finite | false | reject NaN and the infinities where a number is declared |
plainObjects | false | accept only plain objects, and remove an own __proto__ key |
When every definition in the schema is built from type, enum, const,
properties, required, additionalProperties, patternProperties, items,
$ref, if/then/else and allOf — plus annotations, and scalar
assertions such as minLength, pattern, minimum, anyOf, oneOf or not,
which are asked of the validator's own test of the position — repairX is a
single pass: each position is checked and repaired where it stands, an if is
evaluated against the value actually there, a missing required object is built
with its required children (to a bounded depth on a recursive definition), and
errors go straight into one array with their paths built only when something is
reported. A valid document comes back as itself with nothing copied. On a 24 MB
bundled OpenAPI document that is well under a hundred milliseconds (bun --conditions development scripts/bench-repair-walk.ts). Any other keyword keeps
the validate-and-repair loop, which honours path, inPlace and coerce and
throws on the walk-only options (skipRefs, onRef, maxDepth, maxWork,
memo, finite, plainObjects) rather than quietly dropping a guard.
Two schema annotations steer it. "x-repair": "drop" — and a false schema —
removes a failing position instead of writing a default: an unknown key under
additionalProperties: false, an array element under items: false, a
drop-marked property. "x-repair": "ref" on a Reference schema turns on
instance $ref following: at a position written
{ if: { type: object, required: [$ref] }, then: Reference, else: T }, the
$ref is resolved as a JSON Pointer against the root document (own properties
only), and its target is checked and repaired as that position's type, with
errors at the target's own path. Each target is walked once per type, chains
and cycles included; one that does not resolve is reported as unresolvedRef
and never created.
parseX agrees with coerceX. Wherever coerceX accepts a document, the
coercing parseX returns the very same value — through a union, an allOf, an
if, a $ref, recursion included — and it only repairs what coerceX would
have rejected. A differential test pins that over random schemas. To get there,
a definition whose own tree has an anyOf/oneOf/allOf/if/not carries an
exact test (matchesX) and the coercion walk (coerceXInput) in front of its
repairing parser, and every definition such a one reaches carries them too. A
schema without combinators emits exactly what it always did; one with them
emits more — about twice the parser code on a large OpenAPI document — which is
what a second, exact opinion on each definition costs. A strict or
stripUnknown parser has no coerceX to agree with and is unchanged.
parse and parseStrict are the same function name under two contracts, so
asking for both is an error rather than a silent choice.
Moving off Ajv
coerceX is built to replace Ajv compiled with { allErrors: true, coerceTypes: true }, and it is better on each count: the answer, the speed and
the safety of the input.
It accepts only what the schema accepts. Anything coerceX hands back as
valid, validateX has just accepted, and a differential fuzz over random
schemas with anyOf, oneOf, allOf and if/then/else checks every
accepted document against an Ajv that does not coerce. Where the two differ, it
is on purpose:
-
nullis never coerced, in either direction, and it is the difference you will meet most: on a real config corpus it was nearly every case where Ajv accepted andcoerceXdid not. Ajv readsnullas"",0orfalsefor a string, number or boolean field, and reads those back asnull. Anullusually means "not set", and turning it into a real value erases that. Here it goes to the validator as written, and the validator rejects it.Before switching, check what your callers do with a rejected document. If one treats rejection as absence (returns
undefined, falls back to a default), a singlenulldeep inside a block makes the whole block vanish without an error. Ajv's coercion used to hide that. Either let the schema saynullis allowed (type: ['string', 'null']), dropnulls before validating, or have the caller surface the errors instead of discarding the value. -
Only clean numerals become numbers.
" "," 1 ","0x10","Infinity"and"1."are rejected. Ajv turns them into0,1,16, a value JSON cannot hold, and1."007"and"1e3"are still coerced. -
At a union, a branch the value already matches wins. Ajv coerces into the first branch that will take the value. Given
anyOf: [{ type: 'string' }, { const: false }], Ajv turnsfalseinto"false";coerceXkeepsfalse. When no branch matches as written, each branch coerces the value its own way and the result is taken only if every branch that then accepts it agrees. Two different readings leave the value alone for the validator to report, so the order the branches were written in never changes the answer. -
A branch that fails leaves nothing behind. Ajv coerces in place while it tries each branch, so under
oneOf: [{ type: 'integer' }, { type: 'string' }]it turns0into"0"while trying the string branch, sees two matches, and rejects a valid document.coerceXaccepts0as written. -
Coercion reaches every applicator. Scalars are coerced through
$ref,allOf(each subschema in turn),if/then/else(towardthenwhen the value matchesif,elseotherwise), and union branches, nested to any depth.
It never touches the input. Ajv coerces in place, so a caller that keeps its
document has to clone it first. coerceX returns a new value that shares
everything it did not touch, or the input itself when nothing needed coercing.
It is faster where the input has to survive. Against Ajv cloning first, so
both leave the caller's document alone (bun run bench:validators:coerce, or
bench:validators:coerce:node):
| schema | runtime | valid input | needs coercing | cannot be coerced |
|---|---|---|---|---|
| small (4 fields) | Bun | 5.4× | 1.7× | 1.5× |
| small (4 fields) | Node | 9.9× | 2.1× | 2.2× |
| order (nested + array) | Bun | 11× | 1.6× | 1.9× |
| order (nested + array) | Node | 8.4× | 1.8× | 2.2× |
| assert-loose | Bun | 19× | 1.4× | 3.0× |
| assert-loose | Node | 36× | 1.6× | 3.1× |
config (unions via $ref) | Bun | 7.2× | 1.7× | 3.7× |
config (unions via $ref) | Node | 5.2× | 1.5× | 4.6× |
Each cell is how many times faster coerceX is than Ajv on the same input,
each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
valid document is the common case and the widest gap: coerceX answers it with
isX and hands the input straight back, without walking it. isX builds
nothing — no error, no path — and stops at the first failure, answering a
$ref with the target's own isX; it calls validateX only for a schema that
folds to a constant. On valid input Ajv rewrites nothing, so it can
also be timed without the clone; coerceX is still ahead there on these
schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
gap above is the clone, so it is real exactly where the caller cannot let Ajv
mutate its input.
The config case is the shape --coerce exists for: every coercible scalar sits
inside a union reached through $ref, and every union branch is tested by a
named function rather than a closure built per call.
The errors differ in one way worth knowing if you key diagnostics on
path + keyword + params: an additionalProperties error points at the
undeclared key (/readme/example_requests) where Ajv points at the object
holding it (/readme). params.additionalProperty names the key in both.
API
generate(rootSchema, rootTypeName, options?)
| Parameter | Type | Default | Description |
|---|---|---|---|
rootSchema | JSONSchema | — | The root schema. $ref and $dynamicRef are resolved recursively. |
rootTypeName | string | — | Name for the root type (e.g. "Config"). |
options.modes | Mode[] | ['types', 'guard', 'validate'] | Which entry points to emit. The default is read-only; anything that rewrites a document is opt-in. |
options.typeSuffix | string | '' | Suffix on every $ref-derived type name. The root name is untouched. |
options.schemas | Record<string, unknown> | — | Documents you have already loaded, keyed by the URI a $ref names them by. |
options.unknownKeys | 'count-keys' | 'count-enumerable' | 'count-keys' | How a closed object's fast path proves it carries no undeclared key. |
options.formats | 'all' | string[] | — | String formats the validators enforce. Unset leaves format an annotation. |
options.branchErrors | boolean | false | Explain a failing anyOf / oneOf with the branch it plainly meant, or with the kinds it accepts when no branch takes the value's kind. |
options.stripUnknown | boolean | false | Build each parsed result from the declared properties only. |
options.readonly | boolean | false | Emit readonly type members. |
options.caseInsensitive | boolean | false | Normalize a mis-cased string onto an enum member it matches. |
options.helpersMode | 'package' | 'embedded' | 'package' | Where the parser half gets its runtime helpers. |
options.helpersImportPrefix | string | './' | Prefix on emitted-helper import specifiers, when helpersMode is 'embedded'. |
options.extensions | SchemaExtensions | — | Schema extensions, passed through to the parser half. |
options.importExt | 'js' | 'ts' | 'js' | Extension on every emitted relative import. 'ts' emits the on-disk paths, so the output runs under Node's type stripping with no build step. |
options.logWarnings | boolean | false | Print the parser half's warnings about the schema, such as a keyword it cannot enforce. |
Returns: Promise<GeneratedFile[]> where GeneratedFile = { filename: string; content: string }.
The package also exports ALL_MODES (every mode but parseStrict, for a caller
who wants the lot) and the types Mode, GenerateOptions, GeneratedFile and
ImportExtension.
Output layout
config.ts the type, plus isX / validateX / coerceX / repairX
config.parse.ts parseX, importing the type from ./config.js
validation-result.ts the shared error and result types, and the runtime helpers
formats.ts the format checks, only when `formats` enforces one the schema names
_helpers/ the parser's runtime helpers, only when `helpersMode` is 'embedded'
index.ts a barrel over all of it
The type lives in config.ts because that is where a reader looks for it. The
parser half moves to config.parse.ts because both generators name their file
after the schema and one of them has to give way. There is exactly one
export type Config in the output, whatever combination of modes you asked for
— which the test suite asserts, over a corpus of schema shapes, by compiling the
result under this repo's own flags.
Ask for no validator mode and there is nothing to collide with, so the parser
keeps both its own filename and the type it already authored, and no
validation-result.ts is emitted at all — a parse-only build has nothing that
could import its 17 KiB of error types and runtime helpers.
Is it faster?
Than the two engines it wraps? No, and it should not be. (Than Ajv, yes — see
Moving off Ajv.) Ask this package for one mode and it emits the exact
bytes the engine that owns that mode emits — which the test suite pins per
mode, by fingerprint, not by reading the output. Identical code cannot
run at a different speed, so there is no runtime claim to make here and none is
made. bun run bench measures it anyway, and the deltas wander either side of
zero between runs, which is what process-level variance looks like when there is
no underlying difference.
What does change is the cold side, and only in the direction you would hope:
| before (both engines directly) | after | |
|---|---|---|
| codegen for the whole matrix | 2.9 ms | 2.7 ms |
| emitted bytes | 46.0 KiB | 45.8 KiB |
| files | 7 | 6 |
declarations of Order | 2 | 1 |
| per-mode codegen | — | equal or slightly lower |
| per-mode bytes | — | identical |
The one that matters is the last row of the middle block. Reaching every mode used to mean running both generators yourself and shipping two trees for one schema, including two declarations of the same type — structurally interchangeable, but two things to keep in step. Now there is one.
Related packages
@amritk/helpers— the shared type definition and fallback tables both engines read@amritk/mjst— the command-line interface
License
MIT