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.
What it validates
The tool implements a solid subset of JSON Schema Draft-07:
Keyword group Keywords Type systemtype (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 TestsgetType
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.
답글 남기기