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

Where the score comes from
should be accepted (551)should be rejected (378)total
Always says yes551 (100%)0 (0%)551 (59.31%)
Luq 1.x512 (92.92%)24 (6.35%)536 (57.70%)
This release551 (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 here

One import. Returns the same Validator<T> the builder returns: validate, parse, pick, pickAll, and issue paths in the same lines[0].sku grammar.

account-validator.ts
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.

envelope-validator.ts
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.

reading-validator.ts
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; // false

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

load-validator.ts
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.

account-schema.ts
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:

account.schema.json
{
  "$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.

KeywordHandlingPlugin and methodNote
$refstructuralresolve-refResolved first. Local pointers only.
definitionsstructural—A container of schemas.
typestructuralthe slot itself"integer" adds .integer(); a type array becomes nullable or a union.
enumstructuraloneOf — .oneOf()The allowed-value list is typed against the field.
constbindliteral — .literal(value)Comparison is structural, not reference equality.
allOfstructural—Every sub-schema becomes a sub-chain.
anyOfstructural—Combined existentially.
oneOfstructural—Exactly one sub-chain must pass. Not the .oneOf() method, which enum drives.
notstructural—
if / then / elsestructuralconditionalSchemaOne conditional rule.
$schemaannotation only—Checked at the root, then discarded; another dialect is refused.
$idstructural—Establishes a base URI; names a sub-schema as "#name".
$comment, title, description, examplesannotation only—
defaultannotation only—Luq never mutates the input, so it has nowhere to land.
readOnly / writeOnlyannotation onlyreadOnly, writeOnly (chain only)The two plugins exist for hand-written chains.

String

Five bind directly. `format` is structural because its VALUE selects the plugin.

KeywordHandlingPlugin and methodNote
minLengthbindstringMin — .min(n)Counts code points, so one astral character is one.
maxLengthbindstringMax — .max(n)Same counting.
patternbindstringPattern — .pattern(re)The ECMA-262 source is compiled once, here; the plugin takes only a RegExp.
formatstructuralone of twenty format pluginsSee the format table below. A name not in that table is an annotation and passes, which is what Draft-07 §7.2 requires.
contentEncodingbindstringContentEncoding — .contentEncoding(name)An unknown encoding is refused at build time.
contentMediaTypebindstringContentMediaType — .contentMediaType(type)

Number

All five bind. Draft-07 spells the strict bounds as separate keywords; the plugins spell them as a second argument.

KeywordHandlingPlugin and methodNote
minimumbindnumberMin — .min(v)
maximumbindnumberMax — .max(v)
exclusiveMinimumbindnumberMin — .min(v, true)Draft-07 §6.2.5: the bound itself is excluded.
exclusiveMaximumbindnumberMax — .max(v, true)
multipleOfbindnumberMultipleOf — .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.

KeywordHandlingPlugin and methodNote
itemsstructuralarrayEach — .each() / tupleBuilder — .builder()A single schema drives .each(); the tuple form drives .builder().
additionalItemsstructuraltupleBuilder — .builder()Meaningful only beside a tuple items.
minItemsbindarrayMinLength — .minLength(n)
maxItemsbindarrayMaxLength — .maxLength(n)
uniqueItemsbindarrayUnique — .unique()Applied only for true; §6.4.3 makes false a no-op.
containsstructuralarrayContainsApplied 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.

KeywordHandlingPlugin and methodNote
propertiesstructuralone .v() per pathA schema produces the declarations you would have written by hand.
requiredbindrequired — .required()The parent’s array becomes one flag per named child.
additionalProperties (boolean)bindobjectAdditionalProperties — .additionalProperties(allowed)The allowed set defaults to the declared sibling keys.
additionalProperties (schema)structuralobjectAdditionalProperties — .additionalPropertiesSchema()A sub-schema is a sub-chain, which no binding can take.
patternPropertiesstructuralobjectPatternPropertiesEvery matching pattern applies, not just the first.
propertyNamesstructuralobjectPropertyNamesOver property names, not values.
dependenciesstructuralobjectDependentRequired / objectDependentSchemasA key list drives the first; a sub-schema the second.
minPropertiesbindobjectMinProperties — .minProperties(n)
maxPropertiesbindobjectMaxProperties — .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.

formatPlugin subpathMethodNote
date-timestringDatetime.datetime()
datestringDate.date()
timestringTime.time()
durationstringDuration.duration()Not in §7.3; 1.x published it.
emailstringEmail.email()
idn-emailstringIdnEmail.idnEmail()
hostnamestringHostname.hostname()
idn-hostnamestringIdnHostname.idnHostname()Does not check the IDNA2008 derived-property table or the Bidi rules; the plugin header says so.
ipv4stringIpv4.ipv4()
ipv6stringIpv6.ipv6()
uristringUrl.url()Parsed with the platform URL constructor, not a second grammar.
urlstringUrl.url()The same plugin, on purpose. Not in §7.3; 1.x published it.
uri-referencestringUriReference.uriReference()Deliberately not stringIriReference — an IRI-reference is a strict superset.
iristringIri.iri()
iri-referencestringIriReference.iriReference()
uri-templatestringUriTemplate.uriTemplate()
json-pointerstringJsonPointer.jsonPointer()
relative-json-pointerstringRelativeJsonPointer.relativeJsonPointer()
regexstringRegex.regex()
uuiduuid.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

CauseCasesWhat it isWhat fixed it
external-ref57External and remote $ref — see “External $ref, without a network” above.A map of documents the caller already has.
reserved-path-segment14__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-escaping9JSON pointers containing ~0, ~1, %25, or an empty token.Decode the whole fragment before splitting it (RFC 6901 §6).
tuple-items6The array form of items combined with additionalItems.A branch no longer re-follows the $ref it was created by.
null-not-observable5null never reaches a rule, because presence is settled first.nullIsValue: inside a sub-schema, null is a value, not an absence.
sibling-keyword-interaction3additionalProperties does not see patternProperties, so it over-rejects.The plugin takes the patterns as well as the declared keys.
ref-identifier-scope3Base URIs established by $id, and #anchor.A RefScope: $id moves the base URI, and it travels with the node.
code-point-string-length2minLength / maxLength counted in code points rather than code units.countCodePoints, iterating code points rather than UTF-16 units.
ref-chain1The rules behind a second-hop $ref are not collected.A branch no longer re-follows the $ref it was created by.
boolean-sub-schema1true / false used as a sub-schema in its own right.As above.
Failing today0

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.

repository root
git submodule update --init --recursive
npm ci
npx jest test/integration/json-schema-suite.test.ts