Developer · 3 min read

JSON Schema Validation: A Practical Guide

The eight keywords that catch almost every real bug, why additionalProperties is the one people forget, and where validation belongs in a system.

JSON.parse() tells you whether a string is syntactically valid JSON. It says nothing about whether the resulting object has the fields you need, in the types you expect, within the ranges your code can handle.

JSON Schema is a vocabulary for stating those expectations as data, so they can be checked at runtime by any language rather than reimplemented in each one.

A schema is just JSON

{ "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } }

That document says: the value must be an object, it must have an id, and if id is present it must be an integer. Three constraints, and it already rejects the majority of malformed payloads people actually see.

The keywords that earn their place

The specification is large. In practice a small subset catches nearly everything:

The working set
KeywordWhat it catches
typeA string where a number was expected, the classic form-input bug
requiredMissing fields, which otherwise surface as undefined three functions later
propertiesPer-field rules for an object
additionalProperties: falseTypos in field names, and fields the sender should not be setting
enumStatus and role fields drifting to values nothing handles
minimum / maximumNegative quantities, impossible ages, out-of-range percentages
minLength / patternEmpty strings that passed a type: string check
itemsArrays whose elements are not all the same shape

The one people forget

By default, JSON Schema ignores properties you did not describe. A schema for { "email": ... } will happily accept { "email": ..., "emial": ... }, because emial is simply not mentioned.

Adding "additionalProperties": false turns that from silence into an error. It is the difference between a typo failing loudly at the boundary and a field quietly arriving as undefined somewhere deep in your code.

The trade-off is real: a strict schema rejects payloads from a newer client that has added a field. For internal APIs where both ends move together, strict is usually right. For public APIs where clients evolve independently, permissive plus explicit deprecation is usually right. Decide deliberately rather than by default.

Composition, and where it goes wrong

allOf, anyOf and oneOf combine subschemas. They are useful and they are where most confusing failures originate.

  • allOf: every subschema must pass. Good for "base fields plus these extra ones".
  • anyOf: at least one must pass. Good for a field accepting more than one shape.
  • oneOf: exactly one must pass. Good for tagged unions, and a frequent source of surprise, because two overlapping branches both matching is a failure, not a success.

The classic oneOf trap is a union whose branches are not mutually exclusive: {"type": "string"} and {"minLength": 1} both match "abc", so the document fails for matching twice. Make branches disjoint, usually with a discriminating const on a type field.

Where validation belongs

Validate at the boundaries, where data enters your control:

  • Incoming API requests, before any handler logic runs. This is the highest-value placement by a wide margin.
  • Responses from third-party APIs, because their contract can change without telling you and the resulting failure is otherwise reported far from its cause.
  • Configuration files at startup, so a bad deploy fails immediately rather than at 3am when the code path is first hit.
  • Message queue payloads, where a poison message can otherwise be retried indefinitely.

Validating between your own internal functions is usually wasted effort: your type system already covers it, and the runtime cost is real on hot paths.

Make the failure readable

A validator that says "invalid" is barely more useful than a crash. A good one names the JSON pointer to the failing value, the keyword that failed, and what was expected: /items/2/quantity: must be >= 1, got -3. Log that; return a sanitised version to the caller without leaking your internal field structure.

Our JSON Schema Validator reports the failing path and keyword for each error, and the JSON Schema Generator will infer a first draft from a sample document, which is almost always faster than writing one from scratch. Treat the generated schema as a starting point: it will be too permissive about required and too specific about enum until you edit it.

Common questions

Which draft should I target?

Draft-07 remains the most widely supported across languages and is the safe default. Later drafts (2019-09 and 2020-12) split some keywords out and change how $ref composes with sibling keywords. If your tooling supports a later draft everywhere in the chain, use it; if any link in the chain is draft-07 only, target draft-07.

Does a schema replace my type definitions?

They solve overlapping problems at different times. Types are checked when you compile; a schema is checked when data arrives at runtime, which is the only moment that matters for input you did not create. Generating one from the other keeps them in step, which is why json-to-typescript and a schema generator are often used together.

Why does my schema pass everything?

Almost always because the constraints are nested in the wrong place. A required array sitting next to properties applies to that object; the same array inside a property definition applies to that property’s own object. If validation seems to accept anything, print the resolved schema and check which level each keyword actually sits at.