JSON Toolkit

JSON Schema: From Basics to Production

Bottom line: There are many keywords, but only a dozen are used every day. Get type/properties/required/$ref right, handle error messages well, and you cover eighty percent of real cases.

Intermediate~14 min readPublished 2026-10-30Updated 2026-10-30
Cover for JSON Schema: From Basics to Production

Bottom line: pin $schema to 2020-12 first, then extract shared structures with $defs + $ref; use oneOf only when exactly one branch must match, and prefer discriminators or anyOf everywhere else.

A schema is not an appendix to the documentation; it is an asset that can be executed, tested, and generated.

1. Get the version straight first

Version $schema value Status Notes
draft-04 http://json-schema.org/draft-04/schema# Outdated Legacy projects and some OpenAPI 2.0 tooling
draft-07 http://json-schema.org/draft-07/schema# Widely used The de facto basis of OpenAPI 3.0
2019-09 https://json-schema.org/draft/2019-09/schema Available Introduced $defs and the annotation vocabulary
2020-12 https://json-schema.org/draft/2020-12/schema Currently recommended Introduced prefixItems, changed the semantics of items

Use 2020-12 for new projects. Note that in 2020-12 items only constrains the "remaining elements" — tuple-style declarations moved to prefixItems. That is the most common trap when migrating from draft-07.

2. A minimal usable schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/user.json",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "age": { "type": "integer", "minimum": 0, "maximum": 150 },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

This schema covers the four most common concerns: type constraints, required fields, range limits, and array element constraints.

3. Keyword map

3.1 General

Keyword Purpose Watch out for
type The type; may be an array "type": ["string", "null"] means nullable
enum Enumerated values Values must be unique
const A single-value constraint Good for fixed values such as a version number
$ref References another schema References the whole schema and does not merge with sibling keywords on the referencing object

3.2 Numbers

multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum. Note that in draft-04 exclusiveMaximum was a boolean (used together with maximum); only from draft-07 onward did it become a standalone numeric keyword — which makes many "schemas copied from old docs" fail under strict mode.

3.3 Strings

minLength, maxLength, pattern (an ECMA-262 regular expression), and format (an annotation).

minLength counts Unicode code points, not UTF-16 units and not bytes. Validating with JavaScript's string.length will disagree with it.

3.4 Arrays

Keyword Purpose
prefixItems The 2020-12 tuple-style constraint, matching items one by one by index
items A uniform constraint on the remaining elements (2020-12)
contains + minContains/maxContains At least/at most how many matching items must be present
uniqueItems Elements must all differ (deep comparison; avoid on large arrays)
minItems / maxItems Length constraints

3.5 Objects

properties, required, patternProperties, additionalProperties, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas.

Three high-frequency misconceptions:

  1. required only requires the key to exist: {"name": ""} is valid. To reject an empty string you must add minLength: 1;
  2. additionalProperties: false magnifies compatibility problems: one extra field from a client fails the whole payload, so evolving the API forces every caller to upgrade in lockstep;
  3. properties does not restrict undeclared keys — by default they are allowed.

3.6 Logical composition

{
  "oneOf": [
    { "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
    { "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
  ]
}
Keyword Semantics Typical failure
allOf All must be satisfied Multiple branches impose conflicting constraints on the same field
anyOf At least one must be satisfied The error message is hard to trace to a specific branch
oneOf Exactly one must be satisfied Data matches two branches and fails, even though it "looks obviously correct"
not Must not be satisfied Rarely used, and the least friendly error messages

oneOf is the keyword most likely to produce "valid data that fails validation". If the branch constraints overlap (for example, both allow extra fields), a single record matches several branches and oneOf fails outright. In that case, switch to anyOf, or use a discriminator field with const inside the branches.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": { "properties": { "kind": { "const": "image" } } },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

The discriminator style is more readable than oneOf, and it yields better errors.

4. Splitting and reuse

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
    "address": {
      "type": "object",
      "required": ["city"],
      "properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
    }
  },
  "type": "object",
  "required": ["total", "shipTo"],
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "shipTo": { "$ref": "#/$defs/address" }
  }
}
  • $defs is the form introduced in 2019-09; draft-07 uses definitions;
  • Cross-file references use relative $ids, but do not rely on fetching remote schemas at runtime in production — bundle them as static files instead;
  • You cannot add constraints alongside a reference ($ref behavior with sibling keywords varies by version). When you need composition, use allOf.

5. Runtime validation with Ajv

import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({
  allErrors: true,
  strict: true,
  allowUnionTypes: true,
});
addFormats(ajv);

const validateOrder = ajv.compile(orderSchema);

export function assertOrder(input: unknown): void {
  if (validateOrder(input)) {
    return;
  }
  const detail = (validateOrder.errors ?? []).map((errorItem) => ({
    path: errorItem.instancePath || '/',
    keyword: errorItem.keyword,
    message: errorItem.message,
    params: errorItem.params,
  }));
  throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}

Key points:

  • Call compile only once, at module top level; recompiling on every request turns a 10 ms validation into hundreds of milliseconds;
  • strict: true surfaces misspelled keywords early (for example minlength instead of minLength), so it is worth enabling;
  • Only ajv-formats makes format: "email" and format: "date-time" actually take effect;
  • 2020-12 requires the ajv/dist/2020 entry point — don't use the default export.

6. How to design error messages

What the validator gives you is "which keyword failed"; what the user wants is "which input box is wrong". It is worth adding a mapping layer:

const FIELD_LABEL: Record<string, string> = {
  '/name': 'Name',
  '/age': 'Age',
  '/tags': 'Tags',
};

/**
 * Convert Ajv errors into display-ready messages.
 * @param errors The list of Ajv errors
 * @returns An array of display messages
 */
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
  return errors.map((errorItem) => {
    const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
    if (errorItem.keyword === 'required') {
      const missing = (errorItem.params as { missingProperty: string }).missingProperty;
      return `${label} is missing the required field ${missing}`;
    }
    if (errorItem.keyword === 'minLength') {
      return `${label} is too short`;
    }
    return `${label} failed validation (${errorItem.keyword})`;
  });
}

On the API side, keep a machine-readable structure so the front end can decide how to present it:

{
  "type": "https://bbdrug.com/problems/validation",
  "title": "Request data validation failed",
  "status": 422,
  "errors": [
    { "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
  ]
}

7. Performance and traps

  • Validation cost grows with schema complexity and data size; multiple oneOf branches cause combinatorial explosion, which becomes very noticeable with thousands of records;
  • uniqueItems performs an O(n²) deep comparison over the array; for large arrays, deduplicate in the business layer instead;
  • Avoid catastrophic backtracking patterns in pattern (such as (a+)+$); this is the classic entry point for ReDoS;
  • When additionalProperties: false is combined with allOf, fields declared in a subschema are judged "additional" by the parent, so you need to merge them into a single schema or use unevaluatedProperties.

8. Checklist

  • [ ] Pin 2020-12 and declare $schema at the top of the file;
  • [ ] Extract shared structures with $defs + $ref instead of copy-pasting;
  • [ ] Prefer discriminators over oneOf;
  • [ ] Reuse the validator's compile result and enable strict mode;
  • [ ] Document clearly whether format is enforced;
  • [ ] Error responses provide the path, the keyword, and a user-facing message;
  • [ ] Put the schema in CI: run the JSON Schema Test Suite cases as regression tests.

FAQ

How should I pick a JSON Schema draft?

Use 2020-12 for new projects and declare $schema at the top of the file. If the schema goes into OpenAPI 3.1, it must also be 2020-12, because 3.1 embeds that version directly; keep draft-07 only for projects still tied to older toolchains. The two behave differently for items and for $ref sibling keywords, so do not mix them in one schema set.

Why does additionalProperties: false magnify compatibility problems?

It treats every undeclared key as an error, so one extra field from a client fails the whole payload. Every caller must then upgrade in lockstep whenever the API evolves, or old clients get rejected. Config-style schemas should close extra fields explicitly, while public APIs need to weigh the compatibility cost.

Should I use oneOf or anyOf?

Use oneOf only when the data must match exactly one branch. If branch constraints can hold at the same time, one record matches several branches and oneOf fails outright; switch to anyOf, or add a const discriminator inside each branch.

References

Related articles

Two-Way Sync Between Schema and TypeScript Types

Types and schemas each cover one segment: one at compile time, one at runtime. The question isn't which to pick, but how to keep the two in sync automatically.

Intermediate~11 min readUpdated 2026-10-30

Key Points for Designing JSON API Data Contracts

Six things are very hard to change once shipped: response envelopes, error formats, pagination shape, representation of IDs and time, the semantics of null versus absent, and version evolution.

Intermediate~13 min readUpdated 2026-10-30

Without additionalProperties, Schema Validation Validates Nothing

A passing validation does not mean clean data. Pydantic ignores unknown fields by default, Zod strips them, and Ajv lets them through — hallucinated parameters and typo'd keys all pass silently. This article shows the three forms of silent acceptance, then gives complete recipes for closing objects, controlling coercion, and tightening every nesting level.

Intermediate~8 min readUpdated 2026-11-02