JSON Schema validation in real projects

8 min read
json
validation
guide

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.

Tools from this article

← All articles