DEV Community

Dev Nestio
Dev Nestio

Posted on

I Built a Browser-Only JSON Schema Validator — Draft-07, $ref, allOf/anyOf/oneOf, if/then/else, 173 Tests

I Built a Browser-Only JSON Schema Validator — Draft-07, $ref, allOf/anyOf/oneOf, if/then/else, 173 Tests

JSON Schema validation usually means pulling in ajv or a similar library. That's entirely reasonable for production code — but for quick schema checking, debugging, or learning how Draft-07 keywords work, you don't want to spin up a Node project just to paste in some JSON.

So I built a zero-dependency browser tool that implements the Draft-07 spec from scratch.

Live tool → json-schema-validator-dev.pages.dev

All tools → devnestio.pages.dev


What it validates

The tool implements a solid subset of JSON Schema Draft-07:

Keyword group Keywords
Type system type (string, number, integer, boolean, array, object, null, arrays of types)
Enumeration enum, const
String minLength, maxLength, pattern, format
Number minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
Array items (schema + tuple), additionalItems, minItems, maxItems, uniqueItems, contains
Object required, properties, additionalProperties, patternProperties, minProperties, maxProperties, dependencies, propertyNames
Combining allOf, anyOf, oneOf, not
Conditionals if / then / else
References $ref (local: #/$defs/..., #/definitions/...)
Formats email, uri, date, date-time, ipv4, uuid

Every error includes:

  • JSON path to the failing value (e.g. user.address.zipCode)
  • Human-readable message (e.g. String does not match pattern "^\d{5}$")
  • Keyword badge so you know exactly which schema rule failed

Architecture: pure recursive validator

The entire validator is a single recursive function validate(data, schema, path, rootSchema). It takes a value, a schema object, the current path string, and the root schema (for $ref resolution), and returns an array of ValidationError objects.

/**
 * @typedef {{ path: string, message: string, keyword: string }} ValidationError
 */

function validate(data, schema, path, rootSchema) {
  path = path || '';
  rootSchema = rootSchema || schema;

  if (schema === true) return [];
  if (schema === false) return [{ path, message: 'Schema is false', keyword: 'false schema' }];

  const errors = [];

  // $ref short-circuits all other keywords (Draft-07 spec)
  if (schema.$ref) {
    const resolved = resolveRef(schema.$ref, rootSchema);
    if (resolved) return validate(data, resolved, path, rootSchema);
    return [{ path, message: `Cannot resolve $ref "${schema.$ref}"`, keyword: '$ref' }];
  }

  // Type check first — no point checking string keywords on a number
  if (schema.type !== undefined && !typeMatches(data, schema.type)) {
    const expected = Array.isArray(schema.type) ? schema.type.join(' | ') : schema.type;
    return [{ path, message: `Expected "${expected}" but got "${getType(data)}"`, keyword: 'type' }];
  }

  if (typeof data === 'string') errors.push(...validateString(data, schema, path));
  if (typeof data === 'number') errors.push(...validateNumber(data, schema, path));
  if (Array.isArray(data))     errors.push(...validateArray(data, schema, path, rootSchema));
  if (isPlainObject(data))     errors.push(...validateObject(data, schema, path, rootSchema));

  // Combiners
  if (schema.allOf) schema.allOf.forEach(s => errors.push(...validate(data, s, path, rootSchema)));
  if (schema.anyOf && !schema.anyOf.some(s => validate(data, s, path, rootSchema).length === 0))
    errors.push({ path, message: 'Does not match any schema in "anyOf"', keyword: 'anyOf' });
  // ...oneOf, not, if/then/else similarly

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

The early return on type mismatch is important: there's no value in reporting "minLength" requires 5 characters when the value isn't even a string.


Handling exclusiveMinimum/Maximum across drafts

Draft-04 uses boolean exclusiveMinimum / exclusiveMaximum paired with minimum / maximum. Draft-06/07 changed them to be standalone numbers. The validator handles both:

function validateNumber(val, schema, path) {
  const errors = [];

  if (typeof schema.minimum === 'number') {
    if (schema.exclusiveMinimum === true) {
      // Draft-04 style
      if (val <= schema.minimum)
        errors.push({ path, message: `${val} must be > ${schema.minimum}`, keyword: 'exclusiveMinimum' });
    } else {
      if (val < schema.minimum)
        errors.push({ path, message: `${val} must be >= ${schema.minimum}`, keyword: 'minimum' });
    }
  }

  // Draft-06/07 style: exclusiveMinimum as a number
  if (typeof schema.exclusiveMinimum === 'number') {
    if (val <= schema.exclusiveMinimum)
      errors.push({ path, message: `${val} must be > ${schema.exclusiveMinimum}`, keyword: 'exclusiveMinimum' });
  }

  // ... same pattern for maximum / exclusiveMaximum

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

This means schemas from older tooling still work correctly.


$ref resolution

Local $ref values like #/$defs/Address or #/definitions/UUID are resolved by walking the JSON pointer path from the root schema:

function resolveRef(ref, rootSchema) {
  if (!ref.startsWith('#')) return null;  // external refs not supported
  const fragment = ref.slice(1);
  if (fragment === '' || fragment === '/') return rootSchema;  // bare # → root
  const parts = fragment.slice(1)
    .split('/')
    .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));  // JSON Pointer unescaping
  let cur = rootSchema;
  for (const part of parts) {
    if (cur === null || typeof cur !== 'object') return null;
    cur = cur[part];
  }
  return cur !== undefined ? cur : null;
}
Enter fullscreen mode Exit fullscreen mode

Per the Draft-07 spec, when a schema has a $ref, all sibling keywords are ignored — $ref short-circuits everything else.


if/then/else

One of the more useful Draft-07 additions is conditional validation:

if (schema.if !== undefined) {
  const ifErrors = validate(data, schema.if, path, rootSchema);
  if (ifErrors.length === 0) {
    // condition passed → apply then
    if (schema.then !== undefined)
      errors.push(...validate(data, schema.then, path, rootSchema));
  } else {
    // condition failed → apply else
    if (schema.else !== undefined)
      errors.push(...validate(data, schema.else, path, rootSchema));
  }
}
Enter fullscreen mode Exit fullscreen mode

This lets you write schemas like "if the type field is 'credit_card', then cardNumber is required" without splitting into separate schemas.


Testing: 173 tests, zero frameworks

The same vm-based extraction technique I use across all DevNest.io tools:

const vm = require('vm');
const modObj = { exports: {} };
vm.runInContext(extractedScript, vm.createContext({
  module: modObj, exports: modObj.exports,
  window: { addEventListener: () => {} },
  document: { getElementById: () => ({}), querySelectorAll: () => [], addEventListener: () => {} },
  Date, JSON, RegExp, Number, Math, Array, Object, String, Boolean, console
}));

const { validate, getType, typeMatches, isValidEmail, ... } = modObj.exports;
Enter fullscreen mode Exit fullscreen mode

Tests cover:

Function Tests
getType 10 — null, boolean, integer vs number, string, array, object
typeMatches 15 — single types, type arrays, integer/number coercion
isValidEmail 10 — valid addresses, missing parts, spaces, double @
isValidUri 8 — schemes, missing scheme, bad scheme start
isValidDate 10 — valid dates, leap years, out-of-range months/days
isValidDateTime 8 — ISO 8601, offsets, milliseconds, invalid formats
isValidIPv4 8 — valid IPs, out-of-range octets, wrong part count
isValidUUID 8 — v1/v4, wrong variant, no hyphens
validateString 15 — minLength, maxLength, pattern, all formats
validateNumber 15 — min/max, exclusive variants (bool + number), multipleOf
validateArray 12 — minItems, maxItems, uniqueItems, items schema, tuple, additionalItems, contains
validateObject 15 — required, properties, additionalProperties, minProperties, maxProperties, patternProperties, dependencies
resolveRef 8 — $defs, definitions, # root, ~1 escaping
validate (integration) 30 — all major keywords end-to-end

All 173 pass with node test/test.js.


UI design decisions

Split pane layout: JSON on the left, Schema on the right, results spanning the full width below. Ctrl+Enter triggers validation from either pane.

Parse errors are shown per-pane: if your JSON is malformed, you see the parse error under the JSON pane immediately — before even clicking Validate.

Error cards show three pieces of information: the JSON path (in monospace, highlighted), the human message, and the failing keyword as a chip. This mirrors how ajv formats errors, making it easier to cross-reference against the spec.

Three built-in examples: User (a well-formed object), Product (nested object + pattern properties), and Invalid (the User schema with deliberate violations) — so you can explore the tool immediately without having to write a schema from scratch.


Try it

json-schema-validator-dev.pages.dev — free, no login, no tracking.

Part of devnestio.pages.dev — a growing collection of browser-only developer tools. Currently 27 tools and counting.

Top comments (0)