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;
}
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;
}
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;
}
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));
}
}
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;
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)