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
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;
}
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
]
}
}
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}`));
});
}
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}` });
}
}
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
Draft 6+ changed it to numeric:
{ "exclusiveMinimum": 10 } // means > 10
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(...);
}
}
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)` });
}
}
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),
};
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.
- 📦 Repo: https://github.com/sen-ltd/json-schema-validator
- 🌐 Live: https://sen.ltd/portfolio/json-schema-validator/
- 🏢 Company: https://sen.ltd/

Top comments (0)