JSON Schema Validation: Catching API Contract Bugs Before Production
How JSON Schema works, why it catches API contract violations that TypeScript misses, and how to write schemas for real-world payloads.
The API contract problem TypeScript cannot solve
TypeScript is excellent at catching type errors at compile time. If you define an interface that says a field is a number and you accidentally pass a string in your own code, the compiler will stop you. That guarantee, however, only applies to code you write — it does not extend to data that arrives at runtime from external sources.
When your application fetches a JSON payload from a third-party API, a microservice, or your own backend, TypeScript has no way to verify that the data actually conforms to the type you declared. You write const user = response.data as User and TypeScript trusts you completely. If the API returns a string where your type expects a number, or omits a field your type marks as required, the compile step passes without complaint. The bug surfaces at runtime — often only in edge cases, sometimes only in production, and frequently in ways that are slow and expensive to trace.
This is the API contract problem. The contract between what a service promises to return and what your code expects to receive is implicit, undocumented, and entirely unenforced at the boundary where the data crosses into your application. JSON Schema is the most widely adopted solution for making that contract explicit and enforcing it at runtime.
What JSON Schema is
JSON Schema is a JSON document that describes the structure, types, and constraints of other JSON documents. It is a vocabulary — defined in an open specification currently at Draft 2020-12 — that lets you state precisely what a valid JSON payload looks like: which fields are required, what type each value must be, what values are allowed, what patterns strings must match, and how nested arrays and objects should be structured.
The key distinction from TypeScript types is that JSON Schema operates on data, not code. A schema can be used by a validator library in any language — JavaScript, Python, Go, Java, Rust — to inspect an actual runtime value and produce a precise list of every constraint it violates. That makes JSON Schema useful at the exact moment TypeScript stops being useful: when data arrives from outside your application.
JSON Schema is also language-agnostic. The same schema file can validate request bodies on a Node.js server, validate responses in a Python test suite, generate documentation, and power interactive API explorers — all without modification.
Basic schema anatomy
Every JSON Schema document is a JSON object. A minimal but complete schema for a simple payload uses five top-level keywords: $schema, type, properties, required, and additionalProperties.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name", "age"],
"additionalProperties": false
}$schema identifies which draft of the specification the document targets. Validators use it to apply the correct rules. type constrains the root value — in this case the payload must be a JSON object. properties maps each key to a sub-schema that describes its value. required lists the keys that must be present — omitting required entirely means every property is optional, which is a common mistake. additionalProperties: false rejects any key not listed in properties, making the schema a strict allowlist rather than a partial description.
A real-world schema: the user object
Real API payloads have required fields, optional fields, string format constraints, and numeric ranges. Here is a schema for a typical user object returned by a REST API:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "email", "role", "createdAt"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string",
"format": "email",
"maxLength": 320
},
"role": {
"type": "string",
"enum": ["admin", "editor", "viewer"]
},
"age": {
"type": "integer",
"minimum": 0,
"maximum": 150
},
"displayName": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
}id, email, role, and createdAt are required. age and displayName are optional — they appear in properties but not in required. The schema will reject a payload where role is "superuser" (not in the enum), where age is -1 or 200 (outside the numeric range), or where id is missing entirely.
Key validation keywords
JSON Schema has a large vocabulary. These are the keywords you will use in most production schemas:
Constrains the JSON type: "string", "number", "integer", "boolean", "array", "object", or "null". Can also be an array of types: { "type": ["string", "null"] }.
Restricts the value to a fixed list: { "enum": ["pending", "active", "closed"] }. Works on any type, not just strings.
Applies a semantic constraint to strings: "email", "uuid", "date-time", "uri", "ipv4". Important caveat: most validators only report format violations if format assertion is explicitly enabled. Do not rely on format alone for security-critical checks.
Sets inclusive numeric bounds. exclusiveMinimum and exclusiveMaximum set exclusive bounds.
Constrains the length of strings. Use maxLength to prevent oversized inputs from reaching your database.
Validates strings against a regular expression: { "pattern": "^[A-Z]{2}\\d{4}$" }. Useful for codes, identifiers, and formatted fields that format keywords do not cover.
Defines the schema for elements of an array: { "type": "array", "items": { "type": "integer" } } requires every element to be an integer.
References another schema by URI, enabling reuse. Define a shared schema under $defs and reference it with { "$ref": "#/$defs/Address" } to avoid duplicating the same sub-schema across multiple places.
Nested objects and arrays: an order with line items
JSON Schema handles nested structures by composing sub-schemas. The items keyword applies a schema to every element of an array. The properties keyword inside a nested object sub-schema works exactly the same as at the root. Here is a schema for an order payload that contains an array of line items:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["orderId", "status", "lineItems", "totalCents"],
"additionalProperties": false,
"properties": {
"orderId": { "type": "string", "format": "uuid" },
"status": {
"type": "string",
"enum": ["pending", "confirmed", "shipped", "cancelled"]
},
"totalCents": { "type": "integer", "minimum": 0 },
"lineItems": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity", "unitPriceCents"],
"additionalProperties": false,
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"unitPriceCents":{ "type": "integer", "minimum": 0 },
"discountCents": { "type": "integer", "minimum": 0 }
}
}
}
}
}The lineItems array must contain at least one element (minItems: 1), and each element must be an object with its own required fields and constraints. A payload where any line item is missing sku, or where quantity is zero, will fail validation with a precise path pointing to the exact element and field that violated the constraint.
Where JSON Schema fits in your stack
JSON Schema is not a single-use tool. The same schema definition can serve multiple purposes across the development lifecycle:
Validate incoming request bodies in your API server before the payload reaches any business logic. Libraries like ajv (Node.js) compile a JSON Schema to an optimized validator function that runs in microseconds. Zod schemas can be converted to JSON Schema with zod-to-json-schema, letting you define types once and validate at runtime without duplication.
Record real API responses as fixture files and run them through your schemas in CI on every pull request. If a backend deploy changes the response shape, the schema test fails before any frontend consumer is affected. This catches breaking changes in APIs your team does not own — before they reach production.
Tools like json-schema-to-markdown and OpenAPI toolchains (which use JSON Schema for component definitions) generate human-readable documentation directly from schemas. When your schema is the source of truth, documentation stays synchronized with validation automatically.
In a microservices architecture, each service can publish a JSON Schema for its events or API responses. Consumer services validate payloads against the producer's published schema on both sides. Tools like Pact use schema-based contracts to verify compatibility between services in CI without requiring a live integration environment.
Common schema mistakes
Schemas that compile without errors can still be ineffective. These are the mistakes that produce schemas that look correct but fail to catch the bugs they were written to prevent:
A schema without a required array treats every property as optional. The payload {} passes validation for any schema missing required, no matter how many fields are defined in properties. Always declare which fields must be present.
By default, JSON Schema allows any key not listed in properties. A payload with misspelled keys, injected fields, or entirely unexpected structure will pass validation silently. Set additionalProperties: false on any schema where the shape should be exactly known.
Defining the type of a top-level array field without providing an items schema validates that the value is an array but places no constraints on what is inside it. An array of nulls, empty objects, or strings where objects are expected will pass. Always pair "type": "array" with an items sub-schema.
The format keyword in JSON Schema is advisory by default — many validators do not enforce it unless you explicitly enable format assertion (ajv requires { allErrors: true, formats: ... } plus the ajv-formats plugin). Do not rely on format: "email" or format: "date-time" for validation unless you have confirmed your validator actively enforces formats.
Schemas written from documentation or from memory often miss nullable fields, optional wrappers, and integer-vs-number distinctions that only appear in actual API responses. Always test a draft schema against at least three real payloads — a typical case, an edge case with optional fields absent, and an error response — before committing it.
JSON Schema vs TypeScript types: what each catches
TypeScript types and JSON Schema address the same underlying question — "does this data have the right shape?" — but they answer it at different moments and under different conditions. Understanding the boundary between them is what makes combining both effective.
| TypeScript types | JSON Schema | |
|---|---|---|
| When it runs | Compile time only | Runtime, on actual data |
| What it validates | Your own code | Data from any source |
| External API responses | No — type assertions are unchecked | Yes — validates actual payload |
| Catches missing required fields | Only in code you write | Yes, including optional-by-default bugs |
| Numeric range constraints | No | Yes (minimum, maximum) |
| String pattern constraints | No | Yes (pattern, format) |
| Generates documentation | With extra tooling (typedoc) | Natively (OpenAPI, etc.) |
| Works across languages | No — TypeScript only | Yes — any JSON Schema validator |
TypeScript catches mistakes in the code you write before it ships. JSON Schema catches data that violates your assumptions after it arrives. Neither is a substitute for the other. Teams that use TypeScript for static analysis and JSON Schema for runtime validation at API boundaries get the guarantees of both without duplicating effort — especially when tools like Zod or TypeBox generate both from a single definition.
How to test a schema quickly
The fastest way to verify a schema is to paste a real payload and the schema into a browser-based validator side by side. You will see immediately whether the payload passes, and if it fails, you will get a list of specific constraint violations with JSON paths pointing to the exact field.
A useful test sequence for any new schema:
// 1. Paste a valid payload — expect it to pass
{
"orderId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "confirmed",
"totalCents": 4999,
"lineItems": [
{ "sku": "WIDGET-A", "quantity": 2, "unitPriceCents": 1999 },
{ "sku": "WIDGET-B", "quantity": 1, "unitPriceCents": 1001 }
]
}
// 2. Remove a required field — expect a validation error
// (remove "status" — validator should report missing required property)
// 3. Pass a wrong type — expect a type error
// (set "quantity": "two" — validator should report string instead of integer)
// 4. Add an unexpected field — expect additionalProperties error (if set to false)
// (add "internalNote": "test" — validator should reject the extra key)Running all four cases against a new schema takes less than two minutes and catches the most common authoring errors before the schema goes anywhere near production code. Use the JSON Schema Validator to run these tests directly in your browser — paste the schema on the left, the payload on the right, and validation runs instantly with error paths highlighted. No setup, no dependencies, no npm install required.