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 }
}
}
}
}
}| Keyword | Constrains |
|---|---|
type | The JSON type |
required | Which properties must be present |
additionalProperties: false | Rejects unknown properties |
enum / const | An allowed set, or one value |
pattern | A regular expression on a string |
minimum / maximum / multipleOf | Numeric range and divisibility |
minLength / maxLength | String length |
items / prefixItems / minItems | Array contents and size |
format | An 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" } } }
}
]
}$defsplus$refavoids copying a shape and prevents the copies from drifting.oneOfmeans exactly one branch matches;anyOfmeans at least one. Choosing the wrong one produces a schema that passes for input you meant to reject.if/then/elseexpresses conditional requirements that would otherwise live in code.$refcan 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- 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.
- Generate types from the schema rather than writing both, so they cannot disagree.
- Validate at every boundary where data enters: request bodies, queue messages, files, third-party webhooks.
- 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.
- 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.
Related
Designing a JSON payload Testing and mocking JSON APIs
Last refreshed 2026-09-18.