← Complete Projects

阅读中文版本

Build a JSON Schema Validator from Scratch

A dependency-free JSON Schema draft-07 subset validator covering types, formats, combinators, and complex keywords like oneOf, $ref, and patternProperties with precise error paths.

advanced~8 hjavascriptvalidationjson-schemaparsing

Project goal

Build a JSON Schema validator implementing a meaningful draft-07 subset: type, enum, const, numeric and string constraints, object properties with required and additionalProperties, array items and uniqueness, combinators, conditional if-then-else, and local $ref resolution, reporting errors as JSON Pointer paths.

Final features

  • Type checking for null, boolean, object, array, number, and string including integer semantics
  • Combinators allOf, anyOf, oneOf, and not with error aggregation explaining which branch failed
  • Nested $ref resolution against a local definitions registry with cycle detection
  • patternProperties and propertyNames bring regex power to object keys
  • Errors report as instance paths with schema paths, so every failure points at both sides

Architecture

A compile step resolves schemas into closures that validate an instance and return a list of error objects, recursive structure falls out of nested closure calls, and the public API is validate(instance, schema) returning { valid, errors }. Compiling per schema once and reusing closures is what makes repeated validation of many documents cheap.

Prerequisites

  • Node.js 20 LTS or later
  • Comfort with recursion and closures
  • Familiarity with JSON Schema documents as a user

Dependencies

  • node >=20 — Runtime and the official JSON Schema test suite runner
  • vitest ^2.1.0 — Unit tests plus the official draft-07 test suite subset

Directory structure

jsonschema/
├── src/
│   ├── index.js
│   ├── compile.js
│   ├── keywords/
│   │   ├── types.js
│   │   ├── numeric.js
│   │   ├── strings.js
│   │   ├── objects.js
│   │   └── arrays.js
│   └── refs.js
├── test/
│   ├── keywords.test.js
│   └── suite.test.js
└── package.json

Phases

1. Core types and scalars

Implement type, enum, const, and the numeric and string keyword families as composable closures returning error lists.

Files: src/keywords/types.js, src/keywords/numeric.js, src/keywords/strings.js

function compileType(schema) {
  const expected = Array.isArray(schema.type) ? schema.type : [schema.type];
  return (instance, path) => {
    const actual = typeOf(instance);
    if (expected.includes(actual)) return [];
    if (actual === 'number' && expected.includes('integer') && Number.isInteger(instance)) return [];
    return [{ instancePath: path, schemaPath: '/type', message: 'expected ' + expected.join('|') + ', got ' + actual }];
  };
}

Expected behavior: Keyword tests cover every type including the integer acceptance of 2.0 and the rejection of 2.5, string length and pattern edge cases, and numeric bounds with exclusive variants.

2. Objects and arrays

Implement properties, required, additionalProperties, minProperties, patternProperties, propertyNames, and the array family items, additionalItems, minItems, maxItems, and uniqueItems.

Files: src/keywords/objects.js, src/keywords/arrays.js

const errors = [];
for (const key of schema.required) {
  if (!(key in instance)) {
    errors.push({ instancePath: path, schemaPath: '/required', message: 'missing required property ' + key });
  }
}
for (const [key, subschema] of Object.entries(schema.properties)) {
  if (key in instance) errors.push(...compiled[key](instance[key], path + '/' + escapePointer(key)));
}

Expected behavior: Object tests cover required reporting every missing key at once, additionalProperties as schema and as boolean, and patternProperties selecting keys by regex. Array tests cover tuple items, uniform items, and uniqueItems with duplicate detection.

3. Combinators and conditionals

Implement allOf, anyOf, oneOf, not, and if-then-else, aggregating branch errors so oneOf failures explain every branch.

Files: src/compile.js

const branches = schema.oneOf.map(compileSub);
return (instance, path) => {
  const results = branches.map((b) => b(instance, path));
  const passing = results.filter((r) => r.length === 0).length;
  if (passing === 1) return [];
  return [{
    instancePath: path,
    schemaPath: '/oneOf',
    message: passing === 0 ? 'no branch matched' : passing + ' branches matched, exactly one required',
    branchErrors: results,
  }];
};

Expected behavior: Combinator tests pass and fail all four combinators with aggregated branch errors, and if-then-else tests cover all three conditional outcomes including the absent else case.

4. Local $ref and cycle safety

Resolve #/definitions/... references against a registry built at compile time, with a depth guard that turns true cycles into a precise error.

Files: src/refs.js

function compileRef(schema, registry, stack) {
  const target = registry.resolve(schema.$ref);
  if (!target) throw new Error('unresolvable $ref: ' + schema.$ref);
  const key = schema.$ref;
  if (stack.has(key)) throw new Error('circular $ref chain through ' + key);
  return compileSchema(target, registry, new Set([...stack, key]));
}

Expected behavior: A recursive tree schema validates nested structures to arbitrary depth through lazy ref compilation, and a truly circular definition raises the circular chain error instead of a stack overflow.

Testing

Run npx vitest. Keyword unit tests table-drive every implemented keyword including boundary values and error path assertions. The suite tier runs the official JSON Schema draft-07 test suite files for the implemented keywords, asserting pass rates and whitelisting the small set of intentionally unimplemented behaviors. Every test asserts the exact instancePath string, since error paths are the product.

Troubleshooting

Errors point at the wrong array element

Array item validation must append /0 style indexes to the path; check that index formatting is plain decimal without quotes.

oneOf reports success when zero branches match

The passing count must equal exactly one; verify the filter counts empty error lists, not truthy returns.

Stack overflow on recursive schemas

Ref compilation must be lazy per instance node, not eager at compile time; the stack guard belongs around instance traversal, and registry resolution must terminate.

Deployment

Publish as a small library with validate as the only public entry, or consume it inside API gateways for request body validation where a dependency audit forbids larger packages. Keep the official test suite runner as a CI job so any keyword regression is measured against shared semantics rather than local expectations.

Known limitations

  • Format assertion for date-time, email, and uri is annotation-only, matching draft-07 optional behavior, rather than validating syntax.
  • Remote $ref over HTTP and schema composition through $merge keywords are out of scope by design.

Project overview

Validators are usually consumed, never read. Building a JSON Schema validator from scratch teaches recursion, closure composition, and error design better than almost any other exercise, because the specification is a small algebra of predicates and the implementation quality lives entirely in the ergonomics of failures.

The scope is a deliberate draft-07 subset: every keyword that real API validation uses, minus remote references and annotation-only extensions. For that subset, the implementation passes the official test suite — which is the difference between a toy and a tool.

The finished library delivers five capabilities:

  • Type checking for null, boolean, object, array, number, and string, including integer semantics (2.0 accepted, 2.5 rejected).
  • Combinators — allOf, anyOf, oneOf, not — with error aggregation explaining which branch failed.
  • Nested $ref resolution against a local definitions registry, with cycle detection that errors instead of overflowing.
  • patternProperties and propertyNames, bringing regex power to object keys.
  • Errors as instance paths with schema paths, so every failure points at both the document and the rule.

The architecture is compile-then-run: a compile step resolves schemas into closures that validate an instance and return a list of error objects, recursive structure falls out of nested closure calls, and the public API is validate(instance, schema) returning { valid, errors }. Compiling each schema once and reusing closures is what makes repeated validation of many documents cheap.

Setup

Verify the prerequisites before starting: Node.js 20 LTS or later, comfort with recursion and closures, and familiarity with JSON Schema documents as a user.

The dependency footprint is deliberately one test-only package — the validator itself has zero runtime dependencies, which is half the reason to build it:

Dependency Version Why it is here
node >=20 Runtime and the official JSON Schema test suite runner
vitest ^2.1.0 Unit tests plus the official draft-07 test suite subset

Create the project and install:

mkdir jsonschema && cd jsonschema
npm init -y
npm install -D vitest@^2.1.0

Configure the environment in package.json — ESM modules throughout, the test script, and the official suite files vendored under test/suite/:

{
  "name": "jsonschema",
  "type": "module",
  "main": "src/index.js",
  "scripts": { "test": "vitest run" }
}

The directory structure mirrors the keyword families: one module per family under src/keywords/, the compiler and reference resolver at the top, and the public API a three-line re-export.

Phase 1: Core types and scalars

The first phase implements type, enum, const, and the numeric and string keyword families as composable closures returning error lists.

// src/keywords/types.js
export function compileType(schema) {
  const expected = Array.isArray(schema.type) ? schema.type : [schema.type];
  return (instance, path) => {
    const actual = typeOf(instance);
    if (expected.includes(actual)) return [];
    if (actual === 'number' && expected.includes('integer') && Number.isInteger(instance)) return [];
    return [{
      instancePath: path, schemaPath: '/type',
      message: 'expected ' + expected.join('|') + ', got ' + actual,
    }];
  };
}

function typeOf(v) {
  if (v === null) return 'null';
  if (Array.isArray(v)) return 'array';
  return typeof v;
}

Two subtleties in this smallest keyword define the conventions everything else follows. typeOf is a three-branch discriminator — null is its own type because typeof null === 'object' is JavaScript’s oldest lie, and arrays are not objects because the specification says so. Integer semantics accept integral floats: 2.0 is an integer per the specification even though typeOf reports number, and the second acceptance clause is the entire rule.

The numeric family — minimum, maximum, exclusiveMinimum, exclusiveBoolean variants, multipleOf — and the string family — minLength, maxLength, pattern — follow the same shape: precompute at compile time (compile the regex once), return empty-or-one errors at run time. Every closure takes (instance, path) and returns an array, which is the interface that makes composition trivial in later phases.

Acceptance for this phase: keyword tests cover every type including the integer acceptance of 2.0 and the rejection of 2.5, string length and pattern edge cases, and numeric bounds with exclusive variants.

Phase 2: Objects and arrays

The second phase implements properties, required, additionalProperties, minProperties, patternProperties, propertyNames, and the array family — items, additionalItems, minItems, maxItems, uniqueItems.

// src/keywords/objects.js
export function compileProperties(schema, compileSub) {
  const compiled = Object.fromEntries(
    Object.entries(schema.properties ?? {}).map(([k, s]) => [k, compileSub(s)]),
  );
  const patternEntries = Object.entries(schema.patternProperties ?? {})
    .map(([re, s]) => [new RegExp(re), compileSub(s)]);

  return (instance, path) => {
    if (typeOf(instance) !== 'object') return [];
    const errors = [];
    for (const key of schema.required ?? []) {
      if (!(key in instance)) {
        errors.push({ instancePath: path, schemaPath: '/required',
          message: 'missing required property ' + key });
      }
    }
    for (const [key, sub] of Object.entries(compiled)) {
      if (key in instance) {
        errors.push(...sub(instance[key], path + '/' + escapePointer(key)));
      }
    }
    for (const [re, sub] of patternEntries) {
      for (const key of Object.keys(instance)) {
        if (re.test(key)) errors.push(...sub(instance[key], path + '/' + escapePointer(key)));
      }
    }
    return errors;
  };
}

The aggregation policy is uniform and it is the phase’s real design: required reports every missing property at once, property recursion concatenates child errors with the parent path prefix, and patternProperties selects keys by regex and validates each match. One policy, applied everywhere, means users learn it once — a schema missing three properties fails once with three errors, not three times with one each.

escapePointer deserves its own mention: a property name containing / or ~ must escape as ~1 and ~0 per the JSON Pointer specification, or the error path for the key a/b reads as two segments. The array family appends plain decimal indexes — /0, /1 — and uniqueItems does duplicate detection, with objects compared by serialization because structural equality is the specification’s requirement.

Acceptance for this phase: object tests cover required reporting every missing key at once, additionalProperties as schema and as boolean, and patternProperties selecting keys by regex. Array tests cover tuple items, uniform items, and uniqueItems with duplicate detection.

Phase 3: Combinators and conditionals

The third phase implements allOf, anyOf, oneOf, not, and if-then-else, aggregating branch errors so oneOf failures explain every branch.

// src/compile.js
function compileOneOf(schema, compileSub) {
  const branches = schema.oneOf.map(compileSub);
  return (instance, path) => {
    const results = branches.map((b) => b(instance, path));
    const passing = results.filter((r) => r.length === 0).length;
    if (passing === 1) return [];
    return [{
      instancePath: path, schemaPath: '/oneOf',
      message: passing === 0
        ? 'no branch matched'
        : passing + ' branches matched, exactly one required',
      branchErrors: results,
    }];
  };
}

Closures compose naturally here, and each combinator is one control-flow idea: allOf is sequential concatenation of error lists, anyOf is short-circuit success or an aggregate failure carrying every branch’s errors, oneOf counts empty error lists — exactly one means valid, zero or two-plus are both failures with different messages — and not inverts a single branch.

The branchErrors field on oneOf failures is the ergonomics decision worth defending: when none of four branches matches, a user sees which came closest and why each failed, nested inside one parent error at the right path. Without it, oneOf failures are unanswerable without re-running the branches by hand.

if-then-else is the conditional trio: validate against if; if that succeeds, require then; if it fails, require else when present — and an instance against a schema with only if and then, failing the if, is valid. The three outcomes each get a test.

Acceptance for this phase: combinator tests pass and fail all four combinators with aggregated branch errors, and if-then-else tests cover all three conditional outcomes including the absent else case.

Phase 4: Local $ref and cycle safety

The final phase resolves #/definitions/... references against a registry built at compile time, with a depth guard that turns true cycles into a precise error.

// src/refs.js
export function compileRef(schema, registry, stack) {
  const target = registry.resolve(schema.$ref);
  if (!target) throw new Error('unresolvable $ref: ' + schema.$ref);
  const key = schema.$ref;
  if (stack.has(key)) throw new Error('circular $ref chain through ' + key);
  return compileSchema(target, registry, new Set([...stack, key]));
}

The correctness mechanism here is lazy compilation per instance node. A recursive tree schema — a node with children that are nodes — must compile the child reference without infinitely compiling itself, so the reference compiles on traversal, not eagerly at compile time; the stack guard then catches definitions that reference themselves through a cycle with no instance structure to bound the recursion, converting what would be a stack overflow into a named error at the offending $ref.

The registry is built from the root schema’s definitions object during the initial compile walk: every #/definitions/<name> pointer resolves against it, unresolvable pointers throw at compile time rather than failing silently at validation time, and remote HTTP references are out of scope by design — the known-limitations section says so explicitly, and the suite whitelist reflects it.

Acceptance for this phase: a recursive tree schema validates nested structures to arbitrary depth through lazy ref compilation, and a truly circular definition raises the circular chain error instead of a stack overflow.

Design trade-offs

Closures over an interpreted keyword walk. Interpreting the schema per validation — walking the schema object and dispatching on keys — is simpler to write and slower per document, with regexes recompiled every run. The closure compilation costs more up front and pays it back on the second document; API gateways validating millions of bodies are the case that matters.

Zero dependencies over Ajv. Ajv is faster and more complete; it is also a dependency tree an audit has to own. A from-scratch subset is exactly as trustworthy as its suite pass rate, which the CI referee measures continuously.

Error paths as strings over structured location objects. A pointer string is one allocation, prints perfectly in logs, and is directly usable with JSON Pointer tooling. Structured objects compose better in theory; in practice every consumer formats them back into a path.

Whitelisted suite gaps over silent gaps. The intentionally unimplemented behaviors are enumerated in the test whitelist, so a suite run either passes, or fails on something genuinely regressed — never on something known and undocumented.

Testing strategy

Run npx vitest. Keyword unit tests table-drive every implemented keyword including boundary values and error path assertions — and every test asserts the exact instancePath string, since error paths are the product. The suite tier runs the official JSON Schema draft-07 test suite files for the implemented keywords, asserting pass rates and whitelisting the small set of intentionally unimplemented behaviors.

The suite is the permanent referee: when a keyword regresses, the failure names the exact suite file and test, which converts an argument about semantics into a diff that either implements the missing behavior or documents it in the known limitations. Local expectations measure what the author thought; the suite measures what the specification says.

A third tier rounds out the suite for a validator: adversarial inputs. Deeply nested instances (a thousand levels of arrays) probe the recursion budget; enormous enum arrays probe compile cost; schemas combining not, oneOf, and patternProperties at every level probe worst-case traversal. None of these appear in the official suite, and all of them appear in real API traffic within a month of deployment. Bounding the failure mode — a depth limit with a clear error, rather than a stack overflow — is the finding these tests exist to force.

Troubleshooting playbook

Errors point at the wrong array element. A failure inside an array item reports the sibling’s path. The cause is index formatting or accumulation — the path must append /0 style plain decimal indexes, without quotes, using the loop’s actual index rather than a captured stale one. The array tests assert exact paths for first, middle, and last elements so any drift fails with a string diff.

oneOf reports success when zero branches match. A document matching no branch validates cleanly. The cause is a passing-count bug — counting truthy returns instead of empty error lists, so an array of errors (truthy) reads as a pass. The fix is results.filter((r) => r.length === 0).length === 1, and the oneOf tests include both the zero-match and double-match failure cases to pin both sides.

Stack overflow on recursive schemas. Compiling a tree schema blows the stack before validating anything. The cause is eager ref compilation — compiling the referenced schema immediately, which references itself, forever. The fix is the lazy pattern from phase 4: compile the reference on traversal, guard the compile stack, and let instance structure bound the depth.

Extending the project

The keyword registry is the growth surface: each keyword is a compile function registered by name, so adding format: date-time with real syntax validation is one module and a whitelist entry flipped. Remote $ref resolution is an async compile variant with a fetch boundary — deliberately out of scope here, and the synchronous API is part of why the core stays simple. A toJSONPath helper on errors is a small convenience layer; a schema-documentation generator that walks the closure tree back into readable text is a real feature with real value.

For deployment, publish as a small library with validate as the only public entry, or consume it inside API gateways for request body validation where a dependency audit forbids larger packages. Keep the official test suite runner as a CI job so any keyword regression is measured against shared semantics rather than local expectations. Benchmarks belong in the project too, and the honest ones measure the compile-amortization claim directly: validate the same document a thousand times against a compiled closure tree, and again through a schema-interpretation path, and report both numbers with the compile time broken out separately. The interesting result is the crossover — how many documents a workload must validate before compilation pays for itself — and that number is the decision criterion for anyone choosing between this library and interpreting their schemas ad hoc. Publishing the benchmark alongside the suite results also keeps performance claims falsifiable: a pull request that regresses per-document latency by re-interpreting the schema on every call fails a number rather than a hunch.

When comparing against Ajv for a real workload, the dimensions that matter are compile time (Ajv generates code and is slower to first validation, faster after), per-document latency at your schema depth, bundle size for browser use, and suite coverage breadth (Ajv implements drafts beyond 07). A from-scratch validator wins on the auditable-code and zero-dependency axes, loses on coverage, and the benchmark decides the rest — which is the entire argument for building it once rather than importing blind.