JSON Schema Validation: Validating Shape, Not Just Syntax
How JSON Schema works, the keywords that matter, and why additionalProperties false is the setting most schemas are missing.
Table of contents
- A schema is itself JSON
- The three defaults that surprise people
- Composition keywords
- Format is advisory unless you enable it
- Schema-first or types-first?
- Validate at the boundary, then trust
- Frequently asked questions
- Which draft should I use?
- Does validation slow down my API?
- Can I validate on the client too?
- How do I return useful errors?
- Related reading
- References
Syntax validation answers "is this parseable JSON?". JSON Schema answers "is this the JSON I expected?" — which is the question that actually prevents bugs.
A schema is itself JSON#
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "email"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email", "maxLength": 254 },
"age": { "type": "integer", "minimum": 0, "maximum": 130 },
"role": { "enum": ["admin", "editor", "viewer"] },
"tags": {
"type": "array",
"items": { "type": "string" },
"maxItems": 10,
"uniqueItems": true
}
}
}The three defaults that surprise people#
Everything is optional unless listed in required. A property defined under properties is described, not required. This is the most common schema bug.
Unknown properties are allowed by default. Without additionalProperties: false, {"id": "1", "emial": "typo@x.com"} validates cleanly — the typo'd field is simply ignored, and the missing email is caught only if it is in required. For API request bodies, set it to false: silently accepting unknown fields is how a client's typo becomes a support ticket.
type: "number" accepts floats. Use "integer" when you mean a whole number.
Composition keywords#
{
"oneOf": [{ "required": ["email"] }, { "required": ["phone"] }]
}allOf— must satisfy all (composition/inheritance)anyOf— at least oneoneOf— exactly one (useful for "either/or, not both")not— must not match
if/then/else handles conditional requirements:
{
"if": { "properties": { "type": { "const": "card" } } },
"then": { "required": ["cardNumber", "expiry"] }
}Format is advisory unless you enable it#
"format": "email" is annotation-only in the specification. Most validators skip it unless you opt in — in Ajv, ajv-formats must be installed and registered. A schema that relies on format for validation without it silently validates nothing.
Schema-first or types-first?#
Two workable approaches, and the choice matters:
Schema as the source of truth. Write JSON Schema, generate TypeScript types from it. Right when the schema is a contract shared across languages, or published as API documentation (OpenAPI embeds JSON Schema).
Types as the source of truth. Define a Zod (or Valibot) schema in TypeScript, get both runtime validation and static types from one declaration, and export JSON Schema when you need it.
const userSchema = z.object({
id: z.uuid(),
email: z.email().max(254),
age: z.number().int().min(0).max(130).optional(),
role: z.enum(['admin', 'editor', 'viewer']),
});
type User = z.infer<typeof userSchema>; // types derived, never driftFor a TypeScript-only codebase the second is almost always better ergonomics — one declaration instead of two that can disagree. Reach for JSON Schema proper when the contract crosses language boundaries.
Validate at the boundary, then trust#
The point of a schema is to have exactly one place where untrusted data becomes trusted:
export async function POST(request: Request) {
const parsed = userSchema.safeParse(await request.json());
if (!parsed.success) {
return Response.json({ errors: parsed.error.issues }, { status: 400 });
}
// From here down, parsed.data is typed and validated. No defensive checks.
await createUser(parsed.data);
}Validating repeatedly deeper in the stack is a sign the boundary is not clear.
Frequently asked questions#
Which draft should I use?#
2020-12 for new work. Draft-07 remains the most widely supported if you need maximum tooling compatibility.
Does validation slow down my API?#
Ajv compiles schemas to JavaScript functions and validates in microseconds. It is not a bottleneck.
Can I validate on the client too?#
Yes, and it improves UX — but the server must validate regardless. Client validation is a convenience; only server validation is a control.
How do I return useful errors?#
Map validator issues to field paths so the client can show them inline. Returning a single "invalid request" string wastes the information the validator gave you.
Related reading#
- JSON Formatting Best Practices
- REST API Design Best Practices
- Check syntax first with the JSON Validator.