DEV Community

SEN LLC
SEN LLC

Posted on

Writing a JSON Schema Draft 7 Validator From Scratch

Writing a JSON Schema Draft 7 Validator From Scratch

JSON Schema validation is one of those libraries you import without thinking — Ajv is the de facto standard. But the core of Draft 7 is maybe 200 lines of recursive validation, and writing it yourself clarifies what the keywords actually mean. type, required, properties, items, oneOf, anyOf, allOf, not, enum, const, format, pattern, minLength/maxLength/minimum/maximum/multipleOf/minItems/maxItems/uniqueItems — all of it.

Why would you write a JSON Schema validator when Ajv exists? Because Ajv is 50KB minified, compiles schemas at runtime, and has enough abstractions that "why is my schema rejecting this?" can be hard to answer. A simpler validator fits the common case and makes every rejection traceable.

🔗 Live demo: https://sen.ltd/portfolio/json-schema-validator/
📦 GitHub: https://github.com/sen-ltd/json-schema-validator

Screenshot

Features:

  • 25+ Draft 7 keywords supported
  • Error list with JSON pointer paths
  • Format validators (email, uri, date, uuid, ipv4, etc.)
  • 3 example schemas (User, Product, Config)
  • Live validation as you type
  • Japanese / English UI
  • Zero dependencies, 87 tests

The recursive validator

The core function walks schema and data together, accumulating errors with paths:

export function validate(schema, data, path = '') {
  const errors = [];

  // Type check first — if type fails, don't check further
  if (schema.type) {
    if (!validateType(data, schema.type)) {
      errors.push({ path, keyword: 'type', message: `expected ${schema.type}` });
      return errors;
    }
  }

  // Generic keywords
  if (schema.enum && !schema.enum.some(v => deepEqual(data, v))) {
    errors.push({ path, keyword: 'enum', message: 'not in enum' });
  }
  if (schema.const !== undefined && !deepEqual(data, schema.const)) {
    errors.push({ path, keyword: 'const', message: 'const mismatch' });
  }

  // Type-specific keywords (string/number/array/object)
  // ... 

  // Compound (oneOf, anyOf, allOf, not)
  // ...

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

Errors accumulate across all failed keywords. Early return only on type failure, because further checks don't make sense if the value is the wrong type entirely.

JSON pointers for error paths

When an error occurs deep in a nested structure, the caller needs to know where. The standard format is JSON Pointer:

Data:                       Path on error:
{                           /
  "user": {                 /user
    "addresses": [          /user/addresses
      { "city": null }      /user/addresses/0/city
    ]                       
  }
}
Enter fullscreen mode Exit fullscreen mode

The recursive validator constructs paths by appending keys/indices with /:

if (schema.properties) {
  for (const [key, propSchema] of Object.entries(schema.properties)) {
    if (key in data) {
      errors.push(...validate(propSchema, data[key], `${path}/${key}`));
    }
  }
}

if (schema.items && Array.isArray(data)) {
  data.forEach((item, i) => {
    errors.push(...validate(schema.items, item, `${path}/${i}`));
  });
}
Enter fullscreen mode Exit fullscreen mode

Every recursive call gets a deeper path, so errors surface with the full traversal.

multipleOf and float precision

One gotcha: 0.1 * 3 is not exactly 0.3 in IEEE 754. A naive data % multipleOf === 0 check fails on simple decimal values:

if (schema.multipleOf !== undefined) {
  const remainder = Math.abs(data / schema.multipleOf - Math.round(data / schema.multipleOf));
  if (remainder > 1e-10) {
    errors.push({ path, keyword: 'multipleOf', message: `not multiple of ${schema.multipleOf}` });
  }
}
Enter fullscreen mode Exit fullscreen mode

Divide, round, compare. An epsilon of 1e-10 catches float noise without allowing real errors through.

exclusiveMinimum in two drafts

JSON Schema Draft 4 used boolean exclusive bounds:

{ "minimum": 10, "exclusiveMinimum": true }  // means > 10
Enter fullscreen mode Exit fullscreen mode

Draft 6+ changed it to numeric:

{ "exclusiveMinimum": 10 }  // means > 10
Enter fullscreen mode Exit fullscreen mode

A backwards-compatible validator handles both:

if (schema.exclusiveMinimum !== undefined) {
  if (typeof schema.exclusiveMinimum === 'number') {
    if (data <= schema.exclusiveMinimum) errors.push(...);
  } else if (schema.exclusiveMinimum === true && schema.minimum !== undefined) {
    if (data <= schema.minimum) errors.push(...);
  }
}
Enter fullscreen mode Exit fullscreen mode

Real-world schemas from old APIs and documentation mix both styles.

oneOf vs anyOf vs allOf

These compound keywords are worth getting right:

  • allOf: all sub-schemas must validate (AND)
  • anyOf: at least one sub-schema must validate (OR)
  • oneOf: exactly one sub-schema must validate (XOR — exclusive OR)
if (schema.oneOf) {
  const passingCount = schema.oneOf.filter(sub => validate(sub, data, path).length === 0).length;
  if (passingCount !== 1) {
    errors.push({ path, keyword: 'oneOf', message: `${passingCount} matched (expected exactly 1)` });
  }
}
Enter fullscreen mode Exit fullscreen mode

oneOf is the one people most often want when they actually want anyOf. It's strictly less useful in most cases — use anyOf unless you really need the exclusivity.

Format validators

export const FORMATS = {
  email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
  uri: (v) => { try { new URL(v); return true; } catch { return false; } },
  date: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) && !isNaN(new Date(v).getTime()),
  uuid: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
  ipv4: (v) => /^(\d{1,3}\.){3}\d{1,3}$/.test(v) && v.split('.').every(n => parseInt(n) <= 255),
};
Enter fullscreen mode Exit fullscreen mode

Formats are assertions in Draft 6+, advisory in Draft 7 — strictly speaking you can choose whether to enforce them. This validator enforces them as hard checks.

Series

This is entry #78 in my 100+ public portfolio series.

Top comments (0)