Standards explained · · 15 min read

JSON Schema in practice: what every developer should know

A working tour of JSON Schema features that solve real problems. The seven types, additionalProperties:false, formats, $defs/refs, if/then/else, composition. The 20% of the spec you'll use 80% of the time.

By The Toolsy team

JSON Schema is one of those technologies that's everywhere — OpenAPI specs, validation libraries in every language, IDE autocomplete for config files, AWS CloudFormation, Kubernetes resources. The spec is fairly stable (Draft 2020-12 is current) and the ecosystem mature. And yet most developers use a tiny slice of it, often the wrong slice, because the documentation is intimidating and most tutorials skim the surface.

This post is a working tour of JSON Schema features that solve real problems, the ones you should reach for first, and the parts of the spec safe to ignore until you need them.

The minimum viable schema

A JSON Schema is itself a JSON document that describes the shape of other JSON documents. The simplest non-trivial schema:

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age":  { "type": "integer" }
  },
  "required": ["name"]
}

This says: the document is an object, has at most two properties (name string, age integer), and name is mandatory. Validate any JSON against it:

{ "name": "Alice", "age": 30 }    // ✓ valid
{ "name": "Bob" }                  // ✓ valid (age is optional)
{ "age": 30 }                      // ✗ missing required "name"
{ "name": 42 }                     // ✗ name must be a string

That's the entire core idea. Everything else is refinement: more specific types, conditional requirements, references between parts, formats for common patterns.

The types and their refinements

JSON Schema has seven types: string, number, integer, boolean, null, array, object. Each type accepts type-specific keywords:

// String refinements
{
  "type": "string",
  "minLength": 3,
  "maxLength": 50,
  "pattern": "^[a-z][a-z0-9-]*$",
  "format": "email"
}

// Number refinements
{
  "type": "number",
  "minimum": 0,
  "exclusiveMaximum": 1,    // 0 ≤ x < 1
  "multipleOf": 0.01        // must be a multiple of 0.01
}

// Array refinements
{
  "type": "array",
  "items": { "type": "string" },
  "minItems": 1,
  "maxItems": 100,
  "uniqueItems": true
}

// Object refinements
{
  "type": "object",
  "properties": { ... },
  "required": [...],
  "additionalProperties": false,
  "minProperties": 1,
  "maxProperties": 10
}

The single most important keyword: additionalProperties

By default, JSON Schema is permissive: properties you don't mention in properties are allowed through. A schema that only declares name and age accepts an object with name, age, and password_hash. This is almost never what you want.

// PERMISSIVE — extra props allowed (the default!)
{
  "type": "object",
  "properties": { "name": { "type": "string" } }
}
// { "name": "Alice", "evil": true } — VALID

// STRICT — extra props rejected
{
  "type": "object",
  "properties": { "name": { "type": "string" } },
  "additionalProperties": false
}
// { "name": "Alice", "evil": true } — INVALID

Set additionalProperties: false on every object schema you control. The default permissiveness is fine for HTTP APIs accepting requests from many clients (you don't want to break consumers who send extra fields), but for internal config files, command-line tool inputs, or anywhere you control both sides — strict is safer.

Formats: the convenient pre-built validators

The format keyword names a recognized format. The spec defines a long list; most validators implement these out of the box:

"format": "email"            // RFC 5321 mailbox
"format": "uri"              // RFC 3986 URI
"format": "uri-reference"    // URI or relative reference
"format": "uuid"             // RFC 4122 UUID
"format": "date-time"        // RFC 3339 ISO 8601 with time
"format": "date"             // RFC 3339 ISO 8601 date only
"format": "time"             // RFC 3339 ISO 8601 time only
"format": "ipv4"             // dotted-decimal
"format": "ipv6"
"format": "hostname"
"format": "json-pointer"     // RFC 6901
"format": "regex"            // valid regex pattern

Important detail: by default, JSON Schema treats unknown format keywords as informational only — they're not validated. To actually enforce format validation in Ajv (the most popular JavaScript validator), opt in:

const Ajv = require('ajv');
const addFormats = require('ajv-formats');

const ajv = new Ajv({ strict: true });
addFormats(ajv);   // wires up email, uri, uuid, date-time, etc.

const validate = ajv.compile(schema);

Without ajv-formats (or equivalent in other validators), your "format": "email" is documentation, not validation.

Composition with allOf, anyOf, oneOf

The boolean composition keywords combine schemas:

An inheritance pattern:

{
  "$defs": {
    "Animal": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "species": { "type": "string" }
      },
      "required": ["name", "species"]
    }
  },
  "allOf": [
    { "$ref": "#/$defs/Animal" },
    {
      "type": "object",
      "properties": {
        "breed": { "type": "string" },
        "isGoodBoy": { "const": true }
      },
      "required": ["breed"]
    }
  ]
}

A discriminated union (an event that has different shapes based on type):

{
  "oneOf": [
    {
      "type": "object",
      "properties": {
        "type": { "const": "click" },
        "x": { "type": "number" },
        "y": { "type": "number" }
      },
      "required": ["type", "x", "y"],
      "additionalProperties": false
    },
    {
      "type": "object",
      "properties": {
        "type": { "const": "scroll" },
        "deltaY": { "type": "number" }
      },
      "required": ["type", "deltaY"],
      "additionalProperties": false
    }
  ]
}

Caveat: oneOf is slow. The validator tries every subschema and counts matches. For large unions, prefer the if/then/else pattern below, which short-circuits.

Conditional schemas: if/then/else

The if/then/else keywords let you express "if the document matches schema X, also require Y":

{
  "type": "object",
  "properties": {
    "country":      { "type": "string" },
    "postal_code":  { "type": "string" }
  },
  "if": {
    "properties": { "country": { "const": "US" } }
  },
  "then": {
    "properties": {
      "postal_code": { "pattern": "^\\d{5}(-\\d{4})?$" }
    }
  },
  "else": {
    "properties": {
      "postal_code": { "minLength": 1, "maxLength": 12 }
    }
  }
}

This is the right tool for discriminated unions where one field controls the schema of others. Faster than oneOf for large discriminators because it evaluates the if once instead of trying every branch.

Defs and refs for reuse

Repeating yourself in a schema is wrong for the same reasons as in code. Use $defs to define reusable sub-schemas and $ref to reference them:

{
  "type": "object",
  "$defs": {
    "Address": {
      "type": "object",
      "properties": {
        "street":  { "type": "string" },
        "city":    { "type": "string" },
        "country": { "type": "string", "minLength": 2, "maxLength": 2 }
      },
      "required": ["street", "city", "country"]
    }
  },
  "properties": {
    "billing":  { "$ref": "#/$defs/Address" },
    "shipping": { "$ref": "#/$defs/Address" }
  }
}

References can also point at external files: "$ref": "common.json#/$defs/Address". Useful for shared schemas across multiple specs in a project.

Error messages: what your users see when validation fails

Ajv produces error objects with instancePath (where in the document the error occurred), schemaPath (which schema rule failed), and message (human-readable description):

const validate = ajv.compile(schema);
const data = { name: 42, age: -5 };
if (!validate(data)) {
  console.log(validate.errors);
}

/*
[
  { instancePath: '/name', schemaPath: '#/properties/name/type',
    keyword: 'type', message: 'must be string' },
  { instancePath: '/age', schemaPath: '#/properties/age/minimum',
    keyword: 'minimum', message: 'must be >= 0' }
]
*/

The default error messages are technical. For user-facing errors, use ajv-errors (lets you attach custom error messages to schemas) or post-process the errors in your application:

const friendly = validate.errors.map(e => {
  const field = e.instancePath.slice(1) || 'root';
  switch (e.keyword) {
    case 'required': return `Missing field: ${e.params.missingProperty}`;
    case 'type':     return `${field}: expected ${e.params.type}`;
    case 'minimum':  return `${field}: must be at least ${e.params.limit}`;
    case 'pattern':  return `${field}: format is invalid`;
    default:         return `${field}: ${e.message}`;
  }
});

Schemas as documentation

Schemas double as API documentation. Tools like Redoc and Swagger UI render OpenAPI specs (which embed JSON Schema) into browsable HTML. Tools like quicktype generate type definitions in any language from a schema.

Add documentation directly to your schema using title, description, examples:

{
  "type": "object",
  "title": "User profile",
  "description": "A user's public profile information",
  "properties": {
    "username": {
      "type": "string",
      "title": "Username",
      "description": "Unique handle, 3-30 lowercase alphanumeric characters",
      "minLength": 3,
      "maxLength": 30,
      "pattern": "^[a-z0-9_]+$",
      "examples": ["alice42", "dev_user_99"]
    }
  }
}

This metadata is ignored by validators but consumed by docs generators and IDEs.

Versioning your schemas

The $schema keyword declares which draft of JSON Schema your schema uses. The current default for new schemas in 2026:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/user-profile.json",
  ...
}

Old drafts (draft-04, draft-07) are still widely used in legacy ecosystems. OpenAPI 3.0 uses a subset of draft-05; OpenAPI 3.1 uses draft 2020-12. If you control the schema and the validator, use 2020-12.

What this tool doesn't do

JSON Schema is for shape validation, not business logic validation. It can check "is this a string between 3 and 30 characters matching a pattern". It can't easily check "is this username already taken in our database" — that needs a runtime call, not a schema rule.

Don't try to express every business rule in JSON Schema. Schemas validate the request format; application code validates business invariants. Each is good at its layer.

Testing your schemas

Schemas, like all code, need tests. The pattern:

const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const schema = require('./user-profile-schema.json');

const ajv = new Ajv();
addFormats(ajv);
const validate = ajv.compile(schema);

describe('user profile schema', () => {
  test('accepts valid profile', () => {
    expect(validate({ username: 'alice42', email: 'a@b.com' })).toBe(true);
  });

  test('rejects missing username', () => {
    expect(validate({ email: 'a@b.com' })).toBe(false);
    expect(validate.errors[0].keyword).toBe('required');
  });

  test('rejects invalid email format', () => {
    expect(validate({ username: 'alice', email: 'not-an-email' })).toBe(false);
  });
});

Write tests for the schemas your service exposes. They catch regressions when refactoring schemas, document the intended boundary, and serve as living examples.

You can test schemas interactively with our JSON Schema validator — paste a schema and data, see whether they match and what errors come up.

What's worth using, in order

  1. Basic types and required. 80% of what you need.
  2. additionalProperties: false. Set on every internal schema.
  3. format with ajv-formats. Free validation for common formats.
  4. $defs and $ref. Eliminate duplication.
  5. if/then/else. Conditional requirements; better than oneOf for discriminated unions.
  6. title, description, examples. Schemas as docs.
  7. Composition (allOf, anyOf, oneOf). When the simpler tools don't fit.

You can build production-grade validation with just the first four. Everything else exists because somewhere, somebody needed it once — but rarely on the critical path.

Found this useful? Share it with a developer who'd want to read it. Have a topic to suggest? Email hello@toolsy.website.

← More posts