JSON Schema: validating structure instead of trusting it
Valid JSON and correct JSON are different problems. A parser tells you the syntax is well formed. It has nothing to say about whether age is a number, whether email is present, or whether someone sent "true" where you expected true. JSON Schema is the standard way to describe those expectations so a machine can check them.
A schema is JSON
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0 },
"tags": { "type": "array", "items": { "type": "string" } }
},
"required": ["id", "email"],
"additionalProperties": false
}
That is the whole idea. Because the schema is itself JSON, it can live beside your code, be version controlled, be served over HTTP, and be consumed by tooling that generates types, documentation and test fixtures from it.
The keyword that surprises everyone
properties does not make anything required. Listing a property describes it if it appears. An empty object {} validates against the schema above minus its required line, because no property is mandatory by default.
Requirement is a separate list, held on the parent object, naming property names as strings. This trips up almost everyone once, usually in production, when an object that was supposed to contain a field turns out to validate happily without it.
The mirror of this is that a property present in the data but absent from properties is allowed by default. additionalProperties: false is what closes the object. Leave it off and unexpected fields pass silently, which is sometimes what you want for forward compatibility and is often a bug.
format is not validation
{ "type": "string", "format": "email" }
By default this asserts nothing. In the specification format is an annotation, not an assertion, and a conforming validator may ignore it entirely.
Ajv, the standard JavaScript validator, ignores it unless you install and register ajv-formats. A schema that looks like it validates email addresses, dates and URIs may be checking only that the value is a string. If you rely on format, confirm your validator enforces it, and add a pattern where the check genuinely matters.
Composition, and the trap in it
Four keywords combine schemas: allOf (all must pass), anyOf (at least one), oneOf (exactly one), not.
oneOf is the usual choice for tagged unions, and the "exactly one" requirement is real: if your branches overlap so that two match, validation fails even though the data is fine. When that happens, either make the branches mutually exclusive with a const discriminator, or use anyOf.
The trap is combining allOf with additionalProperties: false:
{
"allOf": [{ "$ref": "#/$defs/base" }],
"properties": { "extra": { "type": "string" } },
"additionalProperties": false
}
This rejects every valid document. additionalProperties only knows about properties in the same schema object. It cannot see the properties contributed by the referenced schema inside allOf, so those all count as additional and are refused.
There is no elegant fix in the draft based model. Either restate the inherited property names in the outer schema, or drop additionalProperties: false, or use the 2019-09 and later unevaluatedProperties: false, which does account for what sibling keywords matched. That keyword exists precisely because this problem was so common.
Reuse with $ref and $defs
{
"$defs": {
"address": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
},
"type": "object",
"properties": {
"home": { "$ref": "#/$defs/address" },
"work": { "$ref": "#/$defs/address" }
}
}
$ref is a JSON Pointer to another schema, in this file or another. Note the name change: definitions in draft-07 became $defs in 2019-09. Both are just conventional locations, and a $ref can point anywhere, but staying with the convention keeps tooling happy.
Drafts
Versions matter more than they should, because validators support different sets.
| Draft | Notes |
|---|---|
| draft-04 | Still encountered in older tooling |
| draft-07 | Very widely supported, common default |
| 2019-09 | Adds unevaluatedProperties, renames definitions to $defs |
| 2020-12 | Current. Splits items into prefixItems and items |
The 2020-12 change to arrays catches people migrating: tuple validation moved from items taking an array to prefixItems, and items now applies to everything after the prefix. A draft-07 tuple schema silently means something different under 2020-12.
Declare your dialect with $schema at the top of every schema. Without it the validator guesses, and its guess may not be yours.
Where you already use it
If you write OpenAPI, you are writing JSON Schema. OpenAPI 3.1 aligned fully with JSON Schema 2020-12, so schemas are portable between them. OpenAPI 3.0 used a modified subset with its own quirks, notably nullable instead of "type": ["string", "null"], which is the main friction when upgrading.
Package manifests, CI configuration files and editor settings across many tools ship schemas too. That is what gives you autocomplete and inline errors when editing them in a good editor, via SchemaStore.
Validating in practice
In JavaScript, Ajv compiles a schema to a function once and reuses it, which is what makes it fast enough for request validation on a hot path:
import Ajv from "ajv"
import addFormats from "ajv-formats"
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
const validate = ajv.compile(schema)
if (!validate(data)) console.error(validate.errors)
Two options worth knowing. allErrors: true reports every failure rather than stopping at the first, which is what you want for a form and not for a hot path, since it costs time. And Ajv does not coerce types by default: the string "42" fails "type": "integer". That is usually correct, but query strings and form bodies arrive as strings, so either coerce deliberately with coerceTypes or convert before validating.
Python has jsonschema, Go has santhosh-tekuri/jsonschema, and most languages have a maintained implementation. Check which drafts each supports before committing to a dialect.
Getting started on an existing API
You rarely write a schema from nothing. The quicker route is to take a real response, infer a first draft from its structure, then tighten it.
Paste a sample payload into the JSON formatter to confirm it parses and to read the structure clearly, then use the JSON to TypeScript converter to derive the shape and field types. The type definitions map closely onto schema properties, which makes the schema mostly transcription rather than design.
Then tighten deliberately. Add required for fields your code will access without a guard. Add additionalProperties: false only where you genuinely want to reject unknown fields, remembering that it prevents a producer from adding fields without breaking you. Constrain formats and ranges where a wrong value would cause real damage, and leave the rest loose.
Related reading: fixing unexpected token JSON errors covers the parse failures that happen before validation, and XML versus JSON covers the schema story on the other side.