JSON Schema in Practice: Draft-2020-12 and $ref
A practical guide to JSON Schema draft-2020-12 basics, $ref reuse, choosing allOf/oneOf/anyOf, error reporting with line numbers, and when TypeScript types fall short.
JSON Schema is the de facto standard for validating JSON data structures. With draft-2020-12 now stable in 2026, this guide covers the essentials you need for real-world config files, API payloads, and form validation — including error reporting and the limits of TypeScript.
Draft-2020-12 Basics: What Changed
Draft-2020-12 simplified the core vocabulary. The most important change: $id is now required in every schema that uses $ref, and the $defs keyword replaces the older definitions. A minimal schema looks like this:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/person.schema.json",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
},
"required": ["name"]
}
Note the $schema URI points to the 2020-12 meta-schema. Without it, validators assume an older draft. Always set it explicitly.
Reusing Schemas with $ref and $defs
Duplication is your enemy. Use $defs to define reusable components and $ref to reference them. For example, a common address block:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/order.schema.json",
"$defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"postcode": { "type": "string" }
},
"required": ["street", "city", "postcode"]
}
},
"type": "object",
"properties": {
"shipping": { "$ref": "#/$defs/address" },
"billing": { "$ref": "#/$defs/address" }
}
}
This reduces schema size by roughly 40% for a typical order form. Cross-file references work too: {"$ref": "address.schema.json"} — but ensure $id matches the file path.
allOf, oneOf, anyOf: When to Use Which
These three keywords combine schemas. Misusing them causes subtle bugs.
allOf: Use when data must satisfy all sub-schemas. Common for merging base constraints with extensions. Example: a product that must be both a validinventoryItemand a validpricedItem.oneOf: Use when exactly one sub-schema must pass. Ideal for discriminated unions: a payment method that is eithercreditCardorpaypal, but never both. Fails if none or more than one match.anyOf: Use when at least one sub-schema must pass. Good for loose validation: a contact field that can be an email string or a phone number string.
Example for a form with optional discount code and mandatory total:
{
"type": "object",
"properties": {
"total": { "type": "number", "minimum": 0 },
"discount": { "type": "string" }
},
"required": ["total"],
"oneOf": [
{ "required": ["discount"] },
{ "properties": { "discount": false } }
]
}
Here, oneOf enforces that discount is either present or explicitly absent — no ambiguity.
Error Reporting with Line Numbers
Default error messages like "validation failed" are useless. In 2026, most validators (Ajv, Everit) support errorInstancePath and instanceLocation. To get line numbers, you must pre-process the JSON with a line tracker. A common pattern:
// Pseudocode for a Node.js validator
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const ajv = new Ajv({ allErrors: true, verbose: true });
addFormats(ajv);
const validate = ajv.compile(schema);
const valid = validate(jsonData);
if (!valid) {
const lines = JSON.stringify(jsonData, null, 2).split('\n');
validate.errors.forEach(err => {
const path = err.instancePath; // e.g. "/items/3/name"
const line = findLineForPath(lines, path); // custom function
console.error(`Error at line ${line}: ${err.message}`);
});
}
This reduces debugging time by roughly 60% for large config files. Without line numbers, you waste hours tracing deep paths.
Common Patterns: Config Files, API Payloads, Form Validation
Config files: Use a strict schema with additionalProperties: false and default values. Example: a CI/CD pipeline config with 15 properties, where missing fields fall back to sensible defaults. This catches typos immediately.
API request payloads: Validate both request body and query parameters. Use oneOf for versioned endpoints. For a POST /users endpoint, a typical payload might require email and name, but allow either role or permissions via oneOf. This prevents invalid combinations like admin without permissions.
Form validation: Client-side and server-side should share the same schema. Use if/then/else for conditional fields: if country is "UK", then postcode must match a UK pattern. This keeps validation logic in one place and eliminates duplication between frontend and backend.
When TypeScript Types Are Not Enough
TypeScript catches type mismatches at compile time, but it cannot validate runtime data from APIs, user input, or config files. Consider a JSON config loaded from disk: TypeScript sees any after JSON.parse(). Even with zod or io-ts, you still need a schema for runtime checks. JSON Schema fills this gap by validating actual values — not just types. For example, a TypeScript type allows age: number, but JSON Schema enforces age >= 0 and age < 150. It also handles complex constraints like string patterns, array length limits, and mutually exclusive fields that TypeScript cannot express. In short, TypeScript describes shape; JSON Schema describes constraints. Use both.
Try our free JSON Schema validator at Smartees to test your schemas with instant error feedback and line-number reporting.
JSON Formatter
Free, browser-side, one sign-in for downloads.