JSON Schema validation in real projects
JSON Schema gets adopted for one reason and then quietly becomes load-bearing infrastructure for three more: request validation, generated documentation, mock data, and even TypeScript types. Most teams start with a schema that only checks "is this valid JSON" and never grow it past that, missing most of the value.
A schema that actually catches bugs
A weak schema:
{
"type": "object",
"properties": {
"email": { "type": "string" },
"age": { "type": "number" }
}
}This accepts `{ "email": "not an email", "age": -5 }`, `{ "extra": "anything" }`, and an empty object. A schema that catches real bugs constrains format, range, and shape:
{
"type": "object",
"additionalProperties": false,
"required": ["email", "age"],
"properties": {
"email": { "type": "string", "format": "email", "maxLength": 254 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] }
}
}Three additions do most of the work: `additionalProperties: false` rejects unexpected fields (catching typos like `emial`), `required` forces presence, and `enum`/`format`/`minimum`/`maximum` reject values that are technically the right type but semantically wrong.
Conditional validation
Real-world data often has fields that are required only in some cases — a schema with `if`/`then` handles this without a custom validator function:
{
"type": "object",
"properties": {
"paymentMethod": { "enum": ["card", "invoice"] },
"cardNumber": { "type": "string" },
"poNumber": { "type": "string" }
},
"required": ["paymentMethod"],
"if": { "properties": { "paymentMethod": { "const": "card" } } },
"then": { "required": ["cardNumber"] },
"else": { "required": ["poNumber"] }
}This is the kind of rule that would otherwise live buried in application code as a scattered set of `if` statements across a form component and an API handler, each of which can drift out of sync.
Composition with $ref and definitions
Once you have more than two or three schemas, duplication becomes the real cost. Pull shared shapes into `$defs` (or a separate file) and reference them:
{
"$defs": {
"address": {
"type": "object",
"required": ["street", "city", "postalCode"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"postalCode": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"billingAddress": { "$ref": "#/$defs/address" },
"shippingAddress": { "$ref": "#/$defs/address" }
}
}Change the address shape once, and every reference to it updates.
Choosing a validator
The schema itself is just JSON; you need a library to enforce it at runtime. For Node/JS, [Ajv](https://ajv.js.org/) is the de facto standard — it compiles schemas to executable JavaScript functions, making it fast enough for per-request validation on a hot API path:
import Ajv from "ajv";
import addFormats from "ajv-formats";const ajv = new Ajv({ allErrors: true }); addFormats(ajv); // enables "email", "date-time", "uri", etc. const validate = ajv.compile(schema);
if (!validate(payload)) { console.log(validate.errors); // [{ instancePath: "/age", message: "must be >= 0", ... }] } ```
`allErrors: true` is worth the small performance cost during development — collecting every violation instead of stopping at the first one turns validation into useful feedback for API consumers instead of a frustrating one-error-at-a-time loop.
Python has `jsonschema`, Java has `everit`/`networknt`, Go has `santhosh-tekuri/jsonschema` — nearly every language has a mature implementation of one of the standard drafts (2019-09 and 2020-12 are current; watch for draft mismatches, since keyword behavior changed slightly between drafts).
Using schemas beyond validation
Once a schema exists, it stops being just a gatekeeper:
- **Documentation** — tools like Redoc and Swagger UI render JSON Schema (embedded in OpenAPI) directly into readable API docs, so the schema is the single source of truth instead of a separately maintained wiki page.
- **Mock data** — generators can produce realistic sample payloads straight from a schema's constraints, useful for frontend development before a backend endpoint exists.
- **TypeScript types** — `json-schema-to-typescript` and similar tools derive interfaces from a schema, so your validation rules and your types can't silently drift apart.
Common pitfalls
- Forgetting `additionalProperties: false` — this single omission is why most "the schema passed but the data was garbage" bugs happen.
- Using `type: "string"` for numeric-looking fields that arrive as strings from form submissions (e.g. `"42"`) — decide explicitly whether coercion happens before or after validation.
- Validating on the client only — client-side JSON Schema checks are for UX; the same schema must be enforced server-side, since client validation is trivially bypassed.
- Schema drift — a schema that isn't checked in CI against real sample payloads (fixtures) rots quietly as the API evolves.
Treat the schema as a contract test: run it against saved example payloads in your test suite, and any accidental breaking change to the API surface shows up as a failing assertion rather than a support ticket three weeks later.