What a Schema Actually Asserts: required, type, enum, and additionalProperties
A response returns 200 OK, your test greps the body for "id", and the run goes green. Three weeks later the backend renames total to amountCents — still 200, still valid JSON — and the checkout screen renders undefined. Status-code assertions and spot checks passed because they only prove what you remembered to ask. A JSON Schema is the whole question written down once: every field, its type, and which fields must exist, so each response is checked against the contract instead of against your memory.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://acme.dev/contracts/order.schema.json",
"type": "object",
"required": ["id", "status", "total", "currency"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "format": "uuid" },
"status": { "enum": ["pending", "paid", "shipped", "cancelled"] },
"total": { "type": "number", "minimum": 0, "multipleOf": 0.01 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"items": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/lineItem" } },
"notes": { "type": ["string", "null"] }
},
"$defs": {
"lineItem": {
"type": "object",
"required": ["sku", "qty", "price"],
"properties": {
"sku": { "type": "string", "minLength": 1 },
"qty": { "type": "integer", "minimum": 1 },
"price": { "type": "number", "minimum": 0 }
}
}
}
}
Each keyword answers a distinct failure mode, and knowing which one fired is what makes the failure report useful:
| Keyword | Rejects | The bug it would have caught |
|---|---|---|
required |
missing keys, whatever the type | field silently dropped by a serializer |
type |
wrong JSON type (string vs number) | "19.99" sent where the client does arithmetic |
enum |
values outside the closed set | a new status the mobile app cannot render |
additionalProperties: false |
unknown extra keys | a renamed field nobody told the client about |
$ref + $defs |
duplicated, drifting shapes | order items validated one way, invoices another |
minimum / pattern / format |
out-of-range or malformed values | negative totals, lowercase currency codes |
additionalProperties: false deserves the most attention. Without it, a payload can gain or rename fields forever and validation stays green — the exact silent change that breaks typed clients. Turning it off for responses makes an addition visible in review, which is a choice: additions are usually legal, but you want them announced rather than discovered. Note the direction too — for a response schema, additionalProperties: false punishes the server for adding a field; for a request schema it punishes the client for sending one. Decide deliberately per schema.
Two limits worth stating before an interviewer probes them. JSON Schema validates structure, not semantics: nothing here asserts endDate >= startDate, that total equals the sum of line items, or that a status transition is legal — those are business rules and belong in your assertions. And format is famously loose by default: ajv only enforces format: "uuid" when you add ajv-formats, otherwise unknown formats are ignored unless strict mode is on.
One reading habit to build: validation is recursive, so items runs lineItem against every element and the required inside $defs applies to each one — a single schema covers a list of any length. Error paths mirror that structure as JSON pointers (/items/2/qty must be integer), which is what makes a failure actionable instead of "response invalid". Also mind the spelling gap when you copy between worlds: JSON Schema 2020-12 writes "type": ["string", "null"], while OpenAPI 3.0 writes nullable: true and OpenAPI 3.1 dropped nullable in favor of the JSON Schema form. Schemas that "validate differently in the gateway than in the test" are usually this translation bug. The shape you specified in Data-Driven API Testing rows is now enforced mechanically rather than row by row.
Compiling Schemas with ajv: Strict Mode, Error Paths, and Where the Check Runs
A schema nobody executes is documentation. The cheapest place to execute it is immediately after the response arrives — schema compilation is a few milliseconds and validation of a payload is sub-millisecond, so the check costs less than one extra HTTP round trip. The tool you will meet in interviews and in real pipelines is ajv, the standard JSON Schema validator for Node, used behind the scenes by many API frameworks.
# validate captured fixtures with no test code at all
npx ajv-cli validate --spec=draft2020 \
-s contracts/order.schema.json \
-d 'fixtures/*.json' \
-r 'contracts/*.schema.json'
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const validate = ajv.compile(orderSchema); // compile once, reuse per response
if (!validate(await res.json())) {
throw new Error(ajv.errorsText(validate.errors, { separator: "\n" }));
}
// /items/0/qty must be integer
// /status must be equal to one of the allowed values
Three configuration choices decide whether this catches real problems. allErrors: true — by default ajv stops at the first error, so a malformed payload reports one symptom and hides the rest; triage wants the full list. strict: true — an unknown or misspelled keyword (requried, type: striing) fails at compile time instead of being silently ignored, which is how a typo in the schema stops being an invisible hole. ajv-formats — format: "uuid" and format: "date-time" are annotations in the specification; without the plugin they validate nothing.
Where you run it is a throughput decision, not a religious one:
| Placement | Latency | Catches | Misses |
|---|---|---|---|
| inside the test, per response | ~0 | every response in every run | nothing, but couples schema to runner |
| CI step over recorded fixtures | ~1s | drift on every PR, no app required | responses not yet recorded |
| nightly against a live environment | ~minutes | server regressions post-deploy | arrives after users saw it |
The recommended shape is both ends: fixtures on every pull request for instant feedback, plus per-response checks in the integration suite for responses the fixtures do not cover. Wire it as a hard gate with an exit code, never a warning nobody reads:
- name: Contract-check recorded fixtures
run: node scripts/validate-fixtures.mjs --schemas 'contracts/*.schema.json' --fixtures 'fixtures/**/*.json'
# prints /items/0/qty must be integer, exits 1 -> PR blocked
Two implementation details separate a fast check from a slow one. Compile once: ajv.compile parses and optimizes the schema (the expensive part, milliseconds) while validate is microseconds — build the validators at module load or cache them by $id, or a 500-response run pays the compile cost 500 times. Report every path: printing ajv.errorsText(errors, { separator: "\n" }) into the CI log (or as a PR annotation) turns a red job into a self-explaining one, so the author fixes the payload or the schema without asking you what broke.
The failure this whole stage exists for is the silent type or field change that still returns 200 — the response that passes a status check and a typeof body.id === 'string' spot check while total quietly becomes a string. One compiled schema replaces fifty hand-written field assertions and, unlike them, cannot be pruned one forgotten line at a time. When the schema itself needs to travel with the whole API description, you stop maintaining it by hand — which is OpenAPI Spec-Driven Testing.
Request Schema, Response Schema, and the Repo Diff That Starts the Argument
One team wrote a single order.schema.json and used it to validate both the POST /orders request and the GET /orders/{id} response. Requests were accepted without an id — fine — but the same loose schema also accepted a response missing id, because nothing distinguished "client does not send this yet" from "server forgot to serialize this". Write two schemas per resource: one for what the client sends, one for what the server guarantees back. They share $defs, not required.
contracts/
order.request.schema.json # POST body: no id, no status, no createdAt
order.response.schema.json # GET body: id/status/createdAt all required
line-item.schema.json # $defs shared by both via $ref
{
"allOf": [
{ "$ref": "line-item.schema.json" },
{ "required": ["sku", "qty"] }
],
"properties": {
"id": { "type": "string", "format": "uuid" },
"createdAt": { "type": "string", "format": "date-time" }
}
}
The response schema additionally requires the server-assigned fields; the request schema must not require them, because a client sending a server-generated id is a bug you either reject or ignore on purpose. Keeping the two files separate makes each direction's contract explicit and testable in isolation.
Version the schemas in the repository, next to the code that serves them. That single habit is what turns contract changes into reviewable events: a diff on order.response.schema.json shows up in the same pull request as the endpoint change, and the reviewer sees the contract move with the code instead of reading about it in release notes three weeks later. Classify every edit before you merge it:
| Schema change | Breaking? | Why |
|---|---|---|
| remove a property | yes | existing consumers read it |
add a property to required |
yes | old payloads now fail validation |
narrow an enum |
yes | values in flight become invalid |
| add an optional property | no | additive, but announce it |
widen a type (string → [string, null]) |
yes | strict clients cannot parse null |
loosen required |
no | server sends more than before |
Notice the asymmetry the table exposes: most "cleanup" edits are breaking, and additive changes are the only ones that routinely pass.
$ref across files is where teams get mechanically stuck, so settle it early. ajv will not fetch remote references on its own: either bundle the document first (@apidevtools/json-schema-ref-parser dereferences everything into one file) or register every local schema (addSchema() for each, ajv-cli -r 'contracts/*.json' for the CLI). A reference that cannot be resolved fails at compile time — loud — but a mis-copied $id silently validates against the wrong definition, so give each schema one unique $id and treat it as its primary key. The social half of versioning mirrors it: when the diff reports a breaking edit, the pull request must name the affected consumers — the Pact broker's matrix tells you exactly who calls this operation — and ship with a migration snippet, so the announcement is part of the change rather than a footnote after it.
That classification is exactly what an interviewer means by "backward compatibility", and it is the bridge from validating one payload to protecting many versions at once in API Versioning and Compatibility. Keep schemas in git, require them in the PR template, and let the diff do the arguing.
Quiz
1. A response schema validates green even though the server renamed `total` to `amountCents` and both fields exist. Which schema setting would have failed the response?
2. A payload with three distinct problems reports only the first. Which ajv configuration change fixes that?
3. Your team wants one schema reused for both the POST request and the GET response of a resource. What defect does that cause?
4. Which of these schema edits is backward compatible for existing consumers of a response?
Flashcards
Question
What does JSON Schema validate, and what class of bug can it never catch?
Click to reveal answer
Answer
It validates structure: field presence (required), JSON types, enums, patterns, ranges, and nesting. It cannot validate semantics — cross-field rules like endDate >= startDate, totals matching line items, or legal status transitions — those belong in explicit assertions.
Question
Why set additionalProperties: false on a response schema, and what is the trade-off?
Click to reveal answer
Answer
It rejects unknown keys, so a renamed or newly added field fails validation instead of slipping through — turning silent contract drift into a visible error. Trade-off: a legitimate additive field also fails, so the team must treat schema updates as part of shipping any new field.
Question
Which ajv options matter for test-time validation, and why?
Click to reveal answer
Answer
allErrors: true to report every failing keyword instead of only the first; strict: true so a misspelled keyword fails at compile time rather than being ignored; ajv-formats so format: uuid/date-time are actually enforced (the spec makes formats optional annotations).
Question
Request schema vs response schema — what must differ between them?
Click to reveal answer
Answer
The required set. Requests omit server-assigned fields (id, status, createdAt); responses must require them. Share shapes through $defs/$ref, but keep separate required lists — otherwise you either reject valid requests or accept incomplete responses.
Question
Name three schema changes that are breaking, and one that is additive.
Click to reveal answer
Answer
Breaking: removing a property, adding a property to required, narrowing an enum, widening a type (string → [string, null]). Additive: adding a new optional property or loosening required. Most cleanup edits are breaking — which is why schema diffs belong in the PR.
Revision Notes
Key Takeaways
- 1. A schema replaces fifty hand-written field assertions with one reviewed artifact that cannot be pruned line by line
- 2. required + additionalProperties:false is the pair that catches silent renames — the classic 'still returns 200, still broken' change
- 3. ajv with allErrors and strict mode turns failures into full, typo-proof error paths; formats need ajv-formats to mean anything
- 4. Separate request and response schemas sharing $defs: one required list cannot serve both directions
- 5. Schemas are versioned code — the PR diff is where breaking edits (removals, tightened required, narrowed enums) get caught
Interview Tips
- • "What can't JSON Schema validate?" — semantics: cross-field rules, sums, state transitions; say plainly that those stay in assertions and that formats are optional annotations without a validator like ajv-formats
- • "Response returns 200 but the client breaks — how do you catch it earlier?" — validate every response against a versioned schema in the suite and fixtures in CI; name required + additionalProperties:false as the settings that surface renames
- • "Is adding a field a breaking change?" — for JSON consumers, an optional addition is additive; for strict/typed consumers and for enums, an added value can break exhaustive handling — show you reason about the consumer, not just the wire
- • "How do you keep schemas from drifting from the API?" — schemas live in the repo, change in the same PR as the endpoint, and a CI step compiles them and validates recorded fixtures so drift fails the merge
Cheat Sheet
JSON Schema Validation
Core keywords → failure mode:
| Keyword | Rejects |
|---|---|
required |
missing keys |
type |
wrong JSON type ("19.99" vs 19.99) |
enum |
values outside the closed set |
additionalProperties: false |
unknown/renamed fields |
$ref / $defs |
duplicated, drifting shapes |
minimum, pattern, format |
out-of-range / malformed values |
ajv setup:
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv); // formats are annotations without it
const validate = ajv.compile(schema); // compile once per schema
if (!validate(body)) throw new Error(ajv.errorsText(validate.errors, { separator: "\n" }));
npx ajv-cli validate --spec=draft2020 -s contracts/order.schema.json -d 'fixtures/*.json'
Placement: fixtures on every PR (fast, no app needed) + per-response checks in the integration suite (covers unrecorded responses).
Direction rules: response schema = server guarantees (id/status/createdAt required); request schema = client payload (those fields forbidden or optional). Share $defs, never required.
Breaking vs additive: remove property · add to required · narrow enum · widen type → breaking. Add optional property · loosen required → additive. Schema diff belongs in the same PR as the endpoint change.