Draft-07
JSON Schema
Luq validates a Draft-07 document by converting it into the same field declarations you would have written by hand. There is no second engine: the plan a schema produces is the plan .v() produces, so a converted document composes with everything else in the library.
Conformance, measured
Against the official JSON-Schema-Test-Suite draft7 corpus, required tests only. Skipped cases are counted as failures.
This release
100.00%
929 of 929 cases
A validator that always says yes
59.31%
551 of 929 — the floor the number above should be read against
Luq 1.x, same corpus
57.70%
536 of 929 — below the always-yes floor
| should be accepted (551) | should be rejected (378) | total | |
|---|---|---|---|
| Always says yes | 551 (100%) | 0 (0%) | 551 (59.31%) |
| Luq 1.x | 512 (92.92%) | 24 (6.35%) | 536 (57.70%) |
| This release | 551 (100.00%) | 378 (100.00%) | 929 (100.00%) |
The column that moved is the second one. 1.x rejected 24 of the 378 documents it was supposed to reject, so on this corpus most invalid documents went through. This release rejects all 378, and accepts all 551 it should.
External $ref, without a network
57 of these cases have the suite serve a schema over HTTP and expect the validator to fetch it. Luq does not fetch, and does not need to: it takes a map of documents you already have — { externalDocuments } — so a URI written in a schema can never make the process open a socket, conversion stays synchronous, and nothing is evaluated. The caller decides what is reachable, and it is written in their code.
Which door was measured
The corpus was run through the chain method, with the harness's presence policy and supplied external documents. It accepts the corpus's scalar instances as well as objects. fromJsonSchema also supports root keywords, but returns a Validator<T extends object> for objects. The headline is not a separate measurement of that entry point or of generated validators. Optional suite tests and arbitrary recursion depths are outside this measurement; see the limits below before using it as a compatibility claim.
The corpus is pinned
- Submodule
- test/fixtures/json-schema-suite
- Commit
- f6fd52a0a9
- Directory
- tests/draft7 (optional/ excluded)
- Files / groups / cases
- 37 / 258 / 929
- Corpus sha256
- sha256:323c37914b8c8a41176ccd343cfc110659039c740d380d50ca71f96a5b4860aa
Two front doors
There is no .fromJsonSchema() builder method — the rewrite has no builder-extension mechanism at all. What exists is a function that converts a whole document, and a chain method that constrains one declared field by a document.
1. fromJsonSchema — a whole document
start hereOne import. Returns the same Validator<T> the builder returns: validate, parse, pick, pickAll, and issue paths in the same lines[0].sku grammar.
import { fromJsonSchema } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
type Account = { email: string; age?: number };
const accountValidator = fromJsonSchema<Account>({
type: "object",
properties: {
email: { type: "string", format: "email" },
age: { type: "number", minimum: 18 },
},
required: ["email"],
});
const result = accountValidator.validate({ email: "not-an-email" });
if (result.valid) {
// `data` exists only on this branch — no cast, no non-null assertion.
console.log(result.data.email);
} else {
for (const issue of result.issues) {
// -> "email: stringEmail"
console.log(`${issue.path}: ${issue.code}`);
}
}Convert once, not per request
fromJsonSchema reads the whole document and compiles it. That costs 697.3 µs against 3.2 µs for one validate() on the same shape — so one conversion pays for itself after 207 validate() calls, more than any other measured shape. Convert at startup and keep the Validator; converting inside a request handler makes every request pay a cost meant to be paid once.
Measured on AMD Ryzen 7 5825U with Radeon Graphics, and read from config/perf-baseline.json when this page is built — the method is on the benchmarks page.
2. .jsonSchema() — one declared field
@maroonedog/luq/plugins/jsonSchema adds a chain method, and it takes the plugin bag explicitly. This method also reaches keywords the document root cannot otherwise use: root-level additionalProperties, anyOf, not, if/then/else, minProperties and propertyNames are reachable this way and no other, because fromJsonSchema declares rules per field and the empty path is not a field.
import { Builder } from "@maroonedog/luq";
import { jsonSchemaPlugin } from "@maroonedog/luq/plugins/jsonSchema";
import { jsonSchemaBag } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
type Envelope = { id: string; payload: Record<string, unknown> };
const PAYLOAD_SCHEMA = {
type: "object",
properties: { kind: { type: "string", minLength: 1 } },
required: ["kind"],
additionalProperties: false,
};
const envelopeValidator = Builder()
.use(requiredPlugin)
.use(jsonSchemaPlugin)
.for<Envelope>()
.v("id", (b) => b.string.required())
.v("payload", (b) => b.object.required().jsonSchema(PAYLOAD_SCHEMA, jsonSchemaBag))
.build();
// false — payload.kind is empty
envelopeValidator.validate({ id: "e1", payload: { kind: "" } }).valid;The bundled plugin has its own method name
jsonSchemaFullFeaturePlugin is the same implementation with the 49-plugin bag already inside it, so its chain method takes no bag — and it is called .jsonSchemaFullFeature(), not .jsonSchema(). Two plugins may not claim one method on one slot.
import { Builder } from "@maroonedog/luq";
import { jsonSchemaFullFeaturePlugin } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
type Reading = { sample: number };
// Same conversion, but the bag is already inside the plugin, so there is no
// second argument. The method NAME differs on purpose: one builder may hold
// both plugins, and two plugins may not claim one method on one slot.
const readingValidator = Builder()
.use(jsonSchemaFullFeaturePlugin)
.for<Reading>()
.v("sample", (b) => b.number.jsonSchemaFullFeature({ type: "number", minimum: 0 }))
.build();
readingValidator.validate({ sample: -1 }).valid; // falseA document you only know at run time
This works under a Content-Security-Policy that forbids unsafe-eval: the conversion builds closures, never source text — what that costs against ajv is on the benchmarks page. Convert where the document arrives, cached by whatever identifies the document.
import { fromJsonSchema } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
// Rules loaded at run time, with no type to name. `T` defaults to
// Record<string, unknown> — never `any` — and no declared path is checked.
export async function loadValidator() {
const response = await fetch("/api/validation-rules");
const schema: unknown = await response.json();
return fromJsonSchema(schema);
}Which one to use
./plugins/jsonSchema is billed as the tree-shakeable half, and measured it is not: it gzips to 23,304 B against 25,757 B for the bundle — and that first figure is the plugin alone, which cannot validate a document without a bag of plugins behind it. Both entries, with their ceilings, are on the bundle size table. So use jsonSchemaFullFeature, and reach for ./plugins/jsonSchema when you want the per-field chain method — not to save bytes.
The other direction
Everything above reads a document in. toStandardJsonSchema writes one out: it takes a built validator and returns it with a ~standard.jsonSchema converter attached. What comes back is still a StandardSchemaV1, so the same value goes on working anywhere that takes one.
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { optionalPlugin } from "@maroonedog/luq/plugins/optional";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
import { toStandardJsonSchema } from "@maroonedog/luq/standard-schema";
type Account = { name: string; email: string; age?: number };
const account = toStandardJsonSchema(
Builder()
.use(requiredPlugin)
.use(optionalPlugin)
.use(stringMinPlugin)
.use(stringEmailPlugin)
.use(numberMinPlugin)
.for<Account>()
.v("name", (b) => b.string.required().min(2))
.v("email", (b) => b.string.required().email())
.v("age", (b) => b.number.optional().min(18))
.build()
);
const document = account["~standard"].jsonSchema.input({
target: "draft-2020-12",
});document is then:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 2 },
"email": { "type": "string", "format": "email" },
"age": { "type": "number", "minimum": 18 }
},
"required": ["name", "email"]
}Four things to know
Two targets, no guessing
draft-2020-12 and draft-07. Anything else throws UnsupportedJsonSchemaTargetError — openapi-3.0 descends from draft-04 and is a different lineage, so it is not admitted on a guess. Writing draft-07 silently when 2020-12 was asked for hands you a document you will read under the wrong rules.
input and output return the same schema
A Luq declaration does not carry a transform's result type — a function's return value is unknowable without running it — so a second shape would be invented rather than derived. A field declaring a transform counts as unwritable instead.
An unwritable rule throws
What comes out gets used for validation by whoever receives it, and a silently dropped .custom() produces a schema that admits values it must not, with no trace of the omission. Emitting for documentation or for a form layout instead? Ask for it: libraryOptions: { unrepresentable: "omit" }.
Import before you build
The record of declared calls is made as the chain is walked, and importing @maroonedog/luq/standard-schema is what asks for it — the chain does not keep one on its own. A build() evaluated first produces no record, and DeclarationsUnavailableError says so, naming both causes.
Every Draft-07 keyword
All 46 of them — the count is checked against the draft-07 metaschema's own properties keys by a test. Each is one of three decisions, written down, with no fall-through.
18
bind
Straight to a chain method, with the method name checked by the compiler.
19
structural
The converter expands it — it recurses, or it picks the slot. A sub-schema becomes a sub-chain, which no binding can carry.
9
annotation only
No validation effect in Draft-07, so nothing is generated. Each one says why in the source.
What changed since 1.x, in one place
1.x stored eleven keywords it never read back, compared multipleOf with float modulo, stopped at the first matching patternProperties, let additionalProperties: false reject every property, carried three format tables that disagreed about email, uuid and date-time, and let two bundles claim .fromJsonSchema with registration order picking the winner.
Document plumbing and any-instance keywords
The keywords that belong to no single field type. Each is one of three written decisions; there is no fall-through.
| Keyword | Handling | Plugin and method | Note |
|---|---|---|---|
| $ref | structural | resolve-ref | Resolved first. Local pointers only. |
| definitions | structural | — | A container of schemas. |
| type | structural | the slot itself | "integer" adds .integer(); a type array becomes nullable or a union. |
| enum | structural | oneOf — .oneOf() | The allowed-value list is typed against the field. |
| const | bind | literal — .literal(value) | Comparison is structural, not reference equality. |
| allOf | structural | — | Every sub-schema becomes a sub-chain. |
| anyOf | structural | — | Combined existentially. |
| oneOf | structural | — | Exactly one sub-chain must pass. Not the .oneOf() method, which enum drives. |
| not | structural | — | |
| if / then / else | structural | conditionalSchema | One conditional rule. |
| $schema | annotation only | — | Checked at the root, then discarded; another dialect is refused. |
| $id | structural | — | Establishes a base URI; names a sub-schema as "#name". |
| $comment, title, description, examples | annotation only | — | |
| default | annotation only | — | Luq never mutates the input, so it has nowhere to land. |
| readOnly / writeOnly | annotation only | readOnly, writeOnly (chain only) | The two plugins exist for hand-written chains. |
String
Five bind directly. `format` is structural because its VALUE selects the plugin.
| Keyword | Handling | Plugin and method | Note |
|---|---|---|---|
| minLength | bind | stringMin — .min(n) | Counts code points, so one astral character is one. |
| maxLength | bind | stringMax — .max(n) | Same counting. |
| pattern | bind | stringPattern — .pattern(re) | The ECMA-262 source is compiled once, here; the plugin takes only a RegExp. |
| format | structural | one of twenty format plugins | See the format table below. A name not in that table is an annotation and passes, which is what Draft-07 §7.2 requires. |
| contentEncoding | bind | stringContentEncoding — .contentEncoding(name) | An unknown encoding is refused at build time. |
| contentMediaType | bind | stringContentMediaType — .contentMediaType(type) |
Number
All five bind. Draft-07 spells the strict bounds as separate keywords; the plugins spell them as a second argument.
| Keyword | Handling | Plugin and method | Note |
|---|---|---|---|
| minimum | bind | numberMin — .min(v) | |
| maximum | bind | numberMax — .max(v) | |
| exclusiveMinimum | bind | numberMin — .min(v, true) | Draft-07 §6.2.5: the bound itself is excluded. |
| exclusiveMaximum | bind | numberMax — .max(v, true) | |
| multipleOf | bind | numberMultipleOf — .multipleOf(v) | Compares scaled integers, so multipleOf: 0.1 accepts 0.3. |
Array
Note the renames: the Draft-07 keyword is minItems, the chain method is minLength. The rename is why the keyword map exists: bound to a method that does not exist, the constraint would produce no rule at all.
| Keyword | Handling | Plugin and method | Note |
|---|---|---|---|
| items | structural | arrayEach — .each() / tupleBuilder — .builder() | A single schema drives .each(); the tuple form drives .builder(). |
| additionalItems | structural | tupleBuilder — .builder() | Meaningful only beside a tuple items. |
| minItems | bind | arrayMinLength — .minLength(n) | |
| maxItems | bind | arrayMaxLength — .maxLength(n) | |
| uniqueItems | bind | arrayUnique — .unique() | Applied only for true; §6.4.3 makes false a no-op. |
| contains | structural | arrayContains | Applied existentially. |
Object
Two entries carry a normalisation the compiler enforces: required is an array on the parent, and additionalProperties has a boolean form and a schema form that reach different plugins.
| Keyword | Handling | Plugin and method | Note |
|---|---|---|---|
| properties | structural | one .v() per path | A schema produces the declarations you would have written by hand. |
| required | bind | required — .required() | The parent’s array becomes one flag per named child. |
| additionalProperties (boolean) | bind | objectAdditionalProperties — .additionalProperties(allowed) | The allowed set defaults to the declared sibling keys. |
| additionalProperties (schema) | structural | objectAdditionalProperties — .additionalPropertiesSchema() | A sub-schema is a sub-chain, which no binding can take. |
| patternProperties | structural | objectPatternProperties | Every matching pattern applies, not just the first. |
| propertyNames | structural | objectPropertyNames | Over property names, not values. |
| dependencies | structural | objectDependentRequired / objectDependentSchemas | A key list drives the first; a sub-schema the second. |
| minProperties | bind | objectMinProperties — .minProperties(n) | |
| maxProperties | bind | objectMaxProperties — .maxProperties(n) |
All twenty format names
The seventeen names of Draft-07 §7.3, plus the three 1.x also published (url, uuid, duration). None is out of scope: the “unsupported” column of this table is empty. There is exactly one format table in the library and every entry points at the plugin that owns the grammar, so there is one regex per format and nothing to drift from it.
| format | Plugin subpath | Method | Note |
|---|---|---|---|
| date-time | stringDatetime | .datetime() | |
| date | stringDate | .date() | |
| time | stringTime | .time() | |
| duration | stringDuration | .duration() | Not in §7.3; 1.x published it. |
| stringEmail | .email() | ||
| idn-email | stringIdnEmail | .idnEmail() | |
| hostname | stringHostname | .hostname() | |
| idn-hostname | stringIdnHostname | .idnHostname() | Does not check the IDNA2008 derived-property table or the Bidi rules; the plugin header says so. |
| ipv4 | stringIpv4 | .ipv4() | |
| ipv6 | stringIpv6 | .ipv6() | |
| uri | stringUrl | .url() | Parsed with the platform URL constructor, not a second grammar. |
| url | stringUrl | .url() | The same plugin, on purpose. Not in §7.3; 1.x published it. |
| uri-reference | stringUriReference | .uriReference() | Deliberately not stringIriReference — an IRI-reference is a strict superset. |
| iri | stringIri | .iri() | |
| iri-reference | stringIriReference | .iriReference() | |
| uri-template | stringUriTemplate | .uriTemplate() | |
| json-pointer | stringJsonPointer | .jsonPointer() | |
| relative-json-pointer | stringRelativeJsonPointer | .relativeJsonPointer() | |
| regex | stringRegex | .regex() | |
| uuid | uuid | .uuid() | Not in §7.3; 1.x published it. |
Adding the last four names (idn-email, idn-hostname, uri-reference, regex) took the score from 86.54% to 89.13% on their own: before them, a schema naming one threw at build time and produced no validator at all.
What used to fail
| Cause | Cases | What it is | What fixed it |
|---|---|---|---|
| external-ref | 57 | External and remote $ref — see “External $ref, without a network” above. | A map of documents the caller already has. |
| reserved-path-segment | 14 | __proto__ cannot be a segment of a declared path. A deliberate refusal, to keep prototype pollution unreachable. | Object.defineProperty, so __proto__ is a key and not a setter. |
| ref-pointer-escaping | 9 | JSON pointers containing ~0, ~1, %25, or an empty token. | Decode the whole fragment before splitting it (RFC 6901 §6). |
| tuple-items | 6 | The array form of items combined with additionalItems. | A branch no longer re-follows the $ref it was created by. |
| null-not-observable | 5 | null never reaches a rule, because presence is settled first. | nullIsValue: inside a sub-schema, null is a value, not an absence. |
| sibling-keyword-interaction | 3 | additionalProperties does not see patternProperties, so it over-rejects. | The plugin takes the patterns as well as the declared keys. |
| ref-identifier-scope | 3 | Base URIs established by $id, and #anchor. | A RefScope: $id moves the base URI, and it travels with the node. |
| code-point-string-length | 2 | minLength / maxLength counted in code points rather than code units. | countCodePoints, iterating code points rather than UTF-16 units. |
| ref-chain | 1 | The rules behind a second-hop $ref are not collected. | A branch no longer re-follows the $ref it was created by. |
| boolean-sub-schema | 1 | true / false used as a sub-schema in its own right. | As above. |
| Failing today | 0 | ||
How they fail
- Building the validator throws, so no verdict is reached79
- Built, but the verdict is wrong22
- validate() threw at run time0
An unsupported keyword is refused when the validator is built, not when it runs. A document Luq cannot honour never becomes a validator that quietly says yes.
The one you are most likely to meet
null at a field the document forbids it on. Absence is settled before any rule runs, so nullability is a presence policy rather than a rule.
// A converted { "type": "string" } cannot itself reject null: absence is
// settled before any rule runs, so nullability is the field's PRESENCE policy.
// The converter writes .optional() for every non-required property; a
// hand-written chain says it the same way.
.v("nickname", (b) => b.string.optional().min(2))Limits today
Two. One is a bound on what a conversion will expand; the other is a bound on what may be handed in at all.
A newer dialect is refused, not read under Draft-07's rules
The root $schema is read, and 2019-09, 2020-12, draft-06, draft-04, draft-03, the unversioned alias and any unrecognised meta-schema URI are refused at build time with an UnsupportedDialectError. The dialects disagree about what unchanged keywords mean — $ref siblings (Draft-07 §8.3 replaces the node; 2019-09 onward applies them), whether format asserts, which spelling carries the tuple form of items — so reading one as the other enforces less than the document states, silently. A document with no $schema is read as Draft-07 and is unaffected.
{ assumeDraft07: true } takes the Draft-07 reading deliberately, at the call site where a reviewer reads it. Only the root of the document you hand in is checked: a document supplied through externalDocuments and reached by $ref is not, so a newer-dialect document in that map still loses a $ref sibling without saying so.
A recursive $ref is unrolled three times, and then stops
A recursive definition has infinitely many declared paths and Luq declares finite ones, so the expansion is bounded: one $ref may be entered three times on a branch, because unrolling further is exponential in the number of definitions in the cycle.
Past that depth the document is not checked: the conversion stops adding rules rather than throwing, so a value nested deeper than three turns of the cycle is accepted without being looked at. The cost of the unrolling is paid once, when the validator is built, not on every validate(). A whole conversion also carries a total expansion budget. Passing the corpus does not establish that every real schema fits these bounds.
Reproduce it
Last measured 2026-09-08.
git submodule update --init --recursive
npm ci
npx jest test/integration/json-schema-suite.test.ts