LCM Logo
Programming

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.json
{
  "$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:

schema.json
{ "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:

schema.json
{
  "status": { "enum": ["pending", "active", "closed"] },
  "version": { "const": 2 }
}

Numbers

Ranges and divisibility, including exclusive bounds:

schema.json
{
  "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:

schema.json
{
  "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:

schema.json
{
  "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:

schema.json
{
  "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):

schema.json
{
  "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:

schema.json
{
  "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"):

schema.json
{
  "dependentRequired": { "creditCard": ["billingAddress", "cvv"] }
}

Reuse and recursion

$defs + $ref deduplicate schemas and express arbitrarily recursive structures (trees, nested comments, ASTs):

schema.json
{
  "$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:

schema.json
{
  "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:

R
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:

R
# 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 SchemaRequires host-language code
Types, nullability, required fieldsCross-field comparisons (start < end)
Enums, constants, ranges, multipleOfArithmetic (total == sum(items))
String length, regex patternsUniqueness of a key across array objects
Tuple shape, array length, containsElement ordering / sortedness
Closed objects, key-name rulesReferential integrity, lookups
Mutually exclusive fields (oneOf)Checksums, calendar/timezone semantics
Conditionals on constant valuesState-dependent rules, permissions
Recursive structures, discriminated unionsDefaults, coercion, normalization

On this page