DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A JSON Schema validator is one recursive function — schema, value, path — that calls itself and collects every violation

A JSON Schema validator sounds heavy — draft specs, keyword semantics, edge cases. The core is a single recursive function. You hand it a schema node, a data value, and the path you took to reach that value; it applies every keyword the schema declares, and for the keywords that descend it calls itself on the child with the path extended by one segment. Under 120 lines of real logic, no ajv, no dependency. Here's how I built it.

The schema is just JSON describing a shape

A JSON Schema is itself JSON that describes the shape another value must have: which type each field is, which properties are required, allowed enum values, string patterns, numeric minimum/maximum, array bounds. The validator walks the document and the schema together, recursing into every object property and array element, and records each place a value breaks a rule. You don't even write a parser — the browser's JSON.parse hands you plain objects and arrays to walk.

Parse both, guard the JSON

The schema and document both arrive as text. Run each through JSON.parse inside a try/catch — a malformed input is its own kind of error and should be reported before validation even starts. Once parsed you have ordinary objects, arrays, numbers, strings, booleans and null. Nothing exotic to walk.

A precise JSON type test

JSON has seven types and JavaScript blurs a few, so I wrote one function that answers "does this value match this schema type?". The traps: typeof null === "object", arrays are objects too, integer means a whole number (not a digit string), and 3.5 passes number but fails integer.

function matchesType(v, t){
  switch (t){
    case "integer": return typeof v === "number" && Number.isInteger(v);
    case "number":  return typeof v === "number" && !Number.isNaN(v);
    case "array":   return Array.isArray(v);
    case "object":  return v !== null && typeof v === "object" && !Array.isArray(v);
    case "null":    return v === null;
    default:        return typeof v === t;   // string, boolean
  }
}
Enter fullscreen mode Exit fullscreen mode

The recursive core: (schema, data, path)

One function does everything. It takes the current schema node, the value at this spot, the JSON-pointer path that reached it, the root schema (for $ref), and a shared errors array. Each keyword that applies pushes a { path, keyword, message }. The keywords that go deeper — properties, items, additionalProperties, $ref — call validate again with a longer path. That's the whole architecture; there's no separate "engine".

function validate(schema, data, path, root, errors){
  if (schema === true) return;                       // anything goes
  if (typeof schema !== "object" || schema === null) return;
  // apply type, enum, min/max, pattern...
  if (schema.properties && isObject(data))
    for (const k in schema.properties)
      validate(schema.properties[k], data[k], path + "/" + k, root, errors);
}
Enter fullscreen mode Exit fullscreen mode

Descending and resolving $ref

items recurses into every array element with the index appended to the path; additionalProperties: false rejects any key not named in properties; and $ref resolves a local pointer like #/definitions/address against the root and validates the target subschema. Because every descent extends the same shared errors array, when the top-level call returns you already have every violation in the whole tree — no second pass, no assembling results.

Paths are JSON Pointers, and the gotchas

Each violation is tagged with the exact JSON-pointer that locates it — /items/2/sku, with / separators and array indices as segments (a literal / or ~ in a key escapes to ~1/~0). A few things people get wrong: additionalProperties defaults to true, so a typo'd key validates unless you set it false; required only asserts a key exists, it doesn't constrain the value; and in draft-07 a $ref replaces its sibling keywords entirely, so constraints belong inside the referenced definition.

Paste a schema and a document and watch it validate live, every violation pinned to its path:

https://dev48v.infy.uk/solve/day52-json-schema-validator.html

Top comments (0)