Validating with JSON Schema

Describe the payload you accept, validate it at the boundary, compose schemas with references and conditionals, and generate types and documentation from the same source.

Writing the schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/order.json",
  "type": "object",
  "required": ["id", "total_minor", "currency"],
  "additionalProperties": false,
  "properties": {
    "id": { "type": "string", "pattern": "^ord_[a-z0-9]+$" },
    "total_minor": { "type": "integer", "minimum": 0 },
    "currency": { "enum": ["GBP", "USD", "EUR"] },
    "placed_at": { "type": "string", "format": "date-time" },
    "line_items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["sku", "quantity"],
        "properties": {
          "sku": { "type": "string" },
          "quantity": { "type": "integer", "minimum": 1 }
        }
      }
    }
  }
}
KeywordConstrains
typeThe JSON type
requiredWhich properties must be present
additionalProperties: falseRejects unknown properties
enum / constAn allowed set, or one value
patternA regular expression on a string
minimum / maximum / multipleOfNumeric range and divisibility
minLength / maxLengthString length
items / prefixItems / minItemsArray contents and size
formatAn annotation by default - see the warning
⚠️
format is an annotation in current drafts, not an assertion: a validator may accept "2026-13-45" as a date-time unless you enable format checking or add a pattern. Enable format validation explicitly and test it with a deliberately bad value, or the rule you wrote is documentation only.

References and conditionals

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "money": {
      "type": "object",
      "required": ["amount_minor", "currency"],
      "properties": {
        "amount_minor": { "type": "integer" },
        "currency": { "enum": ["GBP", "USD", "EUR"] }
      }
    }
  },
  "type": "object",
  "properties": {
    "subtotal": { "$ref": "#/$defs/money" },
    "total": { "$ref": "#/$defs/money" },
    "discount": {
      "oneOf": [
        { "$ref": "#/$defs/money" },
        { "type": "null" }
      ]
    },
    "status": { "enum": ["draft", "placed", "cancelled"] }
  },
  "allOf": [
    {
      "if": { "properties": { "status": { "const": "cancelled" } }, "required": ["status"] },
      "then": { "required": ["cancelled_at"] },
      "else": { "properties": { "cancelled_at": { "type": "null" } } }
    }
  ]
}
  • $defs plus $ref avoids copying a shape and prevents the copies from drifting.
  • oneOf means exactly one branch matches; anyOf means at least one. Choosing the wrong one produces a schema that passes for input you meant to reject.
  • if/then/else expresses conditional requirements that would otherwise live in code.
  • $ref can point to another file or URL, which is how a shared schema library is assembled - at the cost of a resolution step in the validator.
// Ajv: compile once, validate many
import Ajv from "ajv";
import addFormats from "ajv-formats";

const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);                       // makes format actually assert

const validate = ajv.compile(orderSchema);

if (!validate(payload)) {
  return res.status(422).json({
    error: {
      code: "VALIDATION_FAILED",
      message: "The order payload is not valid.",
      details: validate.errors.map((e) => ({ field: e.instancePath, issue: e.keyword })),
    },
  });
}

One schema, several outputs

# TypeScript types from the schema
npx json-schema-to-typescript schemas/order.json -o src/types/order.d.ts

# Python models
datamodel-codegen --input schemas/order.json --output models/order.py

# generate a valid example, useful for fixtures and docs
npx json-schema-faker schemas/order.json > fixtures/order.json

# documentation, if your toolchain renders it
npx @adobe/jsonschema2md -d schemas -o docs/schemas
  1. Keep the schema in version control next to the code that checks it; a schema that lives only in a wiki is already out of date.
  2. Generate types from the schema rather than writing both, so they cannot disagree.
  3. Validate at every boundary where data enters: request bodies, queue messages, files, third-party webhooks.
  4. Do not validate data you produced and just read back from your own database - that is a shape you control, and validating it on every read is cost without signal.
  5. Test the schema itself with one valid and one deliberately invalid payload; a schema that accepts everything is worse than none, because it looks like a control.
test("rejects an order with no line items", () => {
  expect(validate({ id: "ord_1", total_minor: 0, currency: "GBP", line_items: [] })).toBe(false);
});

test("rejects an unknown property", () => {
  expect(validate({ id: "ord_1", total_minor: 0, currency: "GBP", extra: 1 })).toBe(false);
});

FAQ

Should additionalProperties be false?
Yes at the boundary where you accept external input, because it turns a silent typo into a rejection. Be careful with responses you consume from others: a strict schema makes every additive change on their side a failure for you, so assert on the fields you use instead.
JSON Schema or a validation library?
They are complementary. A schema is a language-independent document you can publish, generate types from and share with consumers; a library like Zod or Pydantic is faster to write and gives you a typed value directly. Emitting the schema from the library is a good middle ground.

Designing a JSON payload Testing and mocking JSON APIs

Last refreshed 2026-09-18.