JSON Schema
How far type checking and value validation can go with JSON Schema alone, and what must be validated in the host language after parsing
Why JSON Schema
JSON Schema is a declarative, language-agnostic contract for JSON documents. A single schema file
can be enforced identically by validators in every major language (ajv in JavaScript,
jsonschema in Python, boon/jsonschema in Rust, everit/networknt in Java), which makes it
the right place to put every rule that can be expressed declaratively. Rules that can't be
expressed in a schema must live in post-parse code in each consuming language — so knowing exactly
where the boundary sits is what keeps validation logic from being duplicated inconsistently.
The current stable dialect is draft 2020-12. Declare it explicitly so every validator applies the same semantics:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schemas/sample.json"
}Type checking
JSON has exactly six types: object, array, string, number, boolean, null. Schema adds
integer (a number with zero fractional part — 1.0 is a valid integer). A field can accept
several types:
{ "type": ["string", "null"] }That is the entire type system. There are no dates, no binary, no 64-bit integer guarantee, no distinction between int and float storage, no tuples-as-a-type, no maps with non-string keys. Everything richer is built from constraints on these six types — or deferred to the host language.
Value validation
Constants and enumerations
const pins a field to one value; enum to a closed set. Both compare by deep equality, so
objects and arrays are legal enum members:
{
"status": { "enum": ["pending", "active", "closed"] },
"version": { "const": 2 }
}Numbers
Ranges and divisibility, including exclusive bounds:
{
"type": "number",
"minimum": 0,
"exclusiveMaximum": 100,
"multipleOf": 0.5
}Strings
Length bounds and full ECMA-262 regular expressions via pattern. Anchor explicitly — patterns
are searched, not matched against the whole string:
{
"type": "string",
"minLength": 3,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9_-]*$"
}format names well-known shapes (date-time, email, uuid, uri, ipv4, …) but is an
annotation by default in 2020-12 — a compliant validator may silently ignore it. Treat
format as documentation unless you have explicitly enabled assertion in every validator in
your stack (e.g. ajv requires the ajv-formats plugin; Python's jsonschema needs
format_checker= passed explicitly). If you cannot guarantee that, back critical formats with a
pattern, or verify them post-parse.
Arrays
Length, element schemas, positional (tuple) validation, uniqueness, and existence quantifiers:
{
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }],
"items": false,
"uniqueItems": true,
"contains": { "type": "string", "pattern": "^ERR" },
"minContains": 1,
"maxContains": 3
}prefixItems validates a fixed-position tuple; items: false after it forbids extra elements.
contains asserts that at least one (bounded by minContains/maxContains) element matches —
a genuine existential quantifier, often overlooked.
Objects
Required keys, per-key schemas, key-name rules, and closed-world enforcement:
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
},
"patternProperties": {
"^x-": { "type": "string" }
},
"propertyNames": { "pattern": "^[a-z][a-zA-Z0-9-]*$" },
"additionalProperties": false,
"minProperties": 1
}additionalProperties: false closes the object to unknown keys — use it by default in API
contracts to catch typos. Note that it only sees keys not matched by properties or
patternProperties in the same schema object; combined with allOf it does not see sibling
subschemas' properties (use unevaluatedProperties: false at the top level for that — it
evaluates across composed subschemas).
Composition and conditionals
This is where JSON Schema goes further than most developers expect.
Boolean combinators
allOf (intersection), anyOf (union), oneOf (exactly one), not (negation):
{
"oneOf": [
{ "required": ["email"] },
{ "required": ["phone"] }
]
}This enforces mutually exclusive alternatives — email or phone, never both, never neither.
Conditional structure
if/then/else makes one part of the document dictate the rules for another:
{
"if": { "properties": { "country": { "const": "US" } }, "required": ["country"] },
"then": { "required": ["zip"], "properties": { "zip": { "pattern": "^\\d{5}(-\\d{4})?$" } } },
"else": { "required": ["postalCode"] }
}Always pair the if with required, otherwise a document missing the discriminator field
vacuously satisfies the if and triggers then.
Property dependencies
dependentRequired ("if key A is present, keys B and C must be too") and dependentSchemas
("if key A is present, apply this whole subschema"):
{
"dependentRequired": { "creditCard": ["billingAddress", "cvv"] }
}Reuse and recursion
$defs + $ref deduplicate schemas and express arbitrarily recursive structures (trees, nested
comments, ASTs):
{
"$defs": {
"node": {
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "string" },
"children": { "type": "array", "items": { "$ref": "#/$defs/node" } }
}
}
},
"$ref": "#/$defs/node"
}Discriminated unions
Combine oneOf with per-variant const tags — the standard pattern for polymorphic payloads:
{
"oneOf": [
{
"properties": { "kind": { "const": "circle" }, "radius": { "type": "number" } },
"required": ["kind", "radius"]
},
{
"properties": { "kind": { "const": "rect" }, "w": { "type": "number" }, "h": { "type": "number" } },
"required": ["kind", "w", "h"]
}
]
}What JSON Schema cannot validate
These are hard limits of the specification, not gaps in a particular validator. Each of these must be checked in the host language after parsing.
Cross-field value comparisons. A schema cannot assert start < end, min <= max, or
password === passwordConfirm. Conditionals can only branch on constant values (const,
enum) — there is no way to reference another field's value in a constraint. The only
workaround is enumerating every combination, which is only feasible for tiny finite domains.
Arithmetic relationships. No sums, no derived values: "total equals the sum of
items[].price" or "percentages add up to 100" are inexpressible. JSON Schema has no
arithmetic at all beyond multipleOf.
Uniqueness of a nested field across array elements. uniqueItems compares whole elements
by deep equality. "Every object in this array has a distinct id" cannot be expressed — two
objects with equal ids but different names pass uniqueItems.
Ordering and inter-element relationships in arrays. Sortedness, monotonically increasing
timestamps, "each element's end precedes the next element's start" — no keyword relates one
element to another.
Referential integrity. "assigneeId must exist in the users array of this (or another)
document" is a join, and schemas cannot look values up — not within the document, not across
documents, not in a database.
Semantic validity beyond shape. A pattern can check a date looks like YYYY-MM-DD but
not that Feb 30 doesn't exist (asserted format: date does catch calendar validity — but see the
annotation caveat above); checksums (Luhn for card numbers, IBAN, ISBN), DNS-resolvable emails,
real timezone offsets for a given date, and any rule requiring external data are all out of reach.
Anything stateful or contextual. "This coupon code is currently active", "the user may only
set role: admin if they are an admin", "this filename doesn't already exist" — validation
against the current state of the world is by definition outside a static document contract.
Numeric precision and representation. JSON Schema inherits JSON's number model: it cannot
require that a value survives 64-bit integer round-tripping (JavaScript silently loses precision
above 2^53), cannot distinguish 1 from 1.0, and cannot constrain decimal precision for
currency safely across languages.
Transformation. default, deprecated, readOnly, title, description are annotations —
metadata surfaced to tooling. A validator never applies a default, coerces a type, trims a
string, or normalizes case. Validation is strictly read-only.
The layered pattern
The practical consequence: validate in two layers, and keep the boundary explicit.
Layer 1 — schema (shared, language-agnostic). Types, required fields, enums, ranges, patterns, tuple shapes, closed objects, conditional structure, discriminated unions. One schema file, versioned with the API, enforced identically everywhere.
Layer 2 — domain rules (per language, post-parse). Everything from the list above, written once per consumer against already-shape-validated data:
import jsonschema
jsonschema.validate(doc, schema) # layer 1
# layer 2: rules JSON Schema cannot express
if doc["start"] >= doc["end"]:
raise ValueError("start must precede end")
if len({item["id"] for item in doc["items"]}) != len(doc["items"]):
raise ValueError("item ids must be unique")Resist the temptation to move layer-1 rules into layer 2 "since we're validating there anyway" — every rule that stays in the schema is a rule you never re-implement, never let drift between consumers, and can hand to any team as a single self-describing file.
Making layer 2 systematic: validated classes (S7 in R)
Ad-hoc if/stop checks after parsing work, but they validate a document once and then hand you
a plain list that can drift into invalid states. The stronger pattern is parse, don't validate:
deserialize into a class whose type system and validators make invalid states unrepresentable.
In R, S7 expresses this directly — property types and validators are the per-field layer-2
checks, and the class validator is exactly where the cross-field rules JSON Schema cannot
express live:
library(S7)
# a reusable validated type: per-field layer-2 check
prop_positive_dbl <- new_property(
class_double,
validator = function(value) {
if (any(value <= 0)) "must be positive"
}
)
Trial <- new_class(
"Trial",
properties = list(
id = class_character,
learning_rate = prop_positive_dbl,
start = class_double,
end = class_double
),
# cross-field rules: inexpressible in JSON Schema, natural here
validator = function(self) {
if (length(self@start) && length(self@end) && self@start >= self@end) {
"@start must precede @end"
}
}
)Validators run at construction and on every @<- assignment, so a Trial object is valid for
its entire lifetime — not just at the deserialization boundary. Pair the class with read_json
/ write_json methods to make the boundary itself typed:
# reading needs no dispatch: one implementation serves every class,
# because the class constructor carries all class-specific validation
read_json <- function(class, path) {
json <- paste(readLines(path), collapse = "\n")
stopifnot(schema_validator(json)) # layer 1: shared schema
doc <- jsonlite::fromJSON(json, simplifyVector = FALSE)
do.call(class, doc) # layer 2: constructor + validators
}
# writing dispatches on the instance
write_json <- new_generic("write_json", "x")
method(write_json, Trial) <- function(x, path) {
# x is valid by construction, so the serialized output
# conforms to the schema without re-checking
props(x) |>
jsonlite::toJSON(auto_unbox = TRUE) |>
writeLines(path)
}read_json(Trial, path) either returns a fully validated object or errors with the offending
rule; write_json is guaranteed to emit schema-conformant JSON because no invalid object can
exist to be serialized. This gives round-trip safety: schema on the way in, validators throughout, and
conformant output on the way out.
The division of labor stays the same as everywhere else in this guide: everything expressible in
JSON Schema belongs in the schema, because S7 validators are visible only to R — a Python or
TypeScript consumer of the same documents never sees them. The class encodes only the remainder
(cross-field comparisons, uniqueness, domain semantics) plus in-memory invariants that outlive
parsing. The same pattern exists in the other layer-2 languages: Pydantic models with
@model_validator in Python, and Zod schemas with .refine()/.superRefine() in TypeScript.
Quick reference: expressible vs. not
| Expressible in JSON Schema | Requires host-language code |
|---|---|
| Types, nullability, required fields | Cross-field comparisons (start < end) |
Enums, constants, ranges, multipleOf | Arithmetic (total == sum(items)) |
| String length, regex patterns | Uniqueness of a key across array objects |
Tuple shape, array length, contains | Element ordering / sortedness |
| Closed objects, key-name rules | Referential integrity, lookups |
Mutually exclusive fields (oneOf) | Checksums, calendar/timezone semantics |
| Conditionals on constant values | State-dependent rules, permissions |
| Recursive structures, discriminated unions | Defaults, coercion, normalization |