fileview.dev

Writing a JSON Schema

12 min read

Written while wiring draft-06 through 2020-12 validation into a browser tool, which is how the draft-mismatch section below stopped being theoretical.

A JSON Schema is itself a JSON document that describes the shape another document must have. You build one by declaring a type, listing the properties, marking which are required, and then tightening each property until invalid data cannot pass.

Most schemas are written after something has already gone wrong — a field arrived as a string when the code expected a number, or an optional key turned out not to be optional. The useful mental model is that a schema is a contract you can execute, and the reason to write one is that the alternative is discovering the contract from a stack trace in production.

A first schema

Start with the document you actually have. Declare its type, name the properties, and say which of them must be present. Everything after this is narrowing.

{
  "id": 4821,
  "email": "ada@example.com",
  "active": true,
  "tags": ["admin", "billing"]
}
The document being described
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "email": { "type": "string", "format": "email" },
    "active": { "type": "boolean" },
    "tags": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": ["id", "email"],
  "additionalProperties": false
}
A schema for it

Narrowing the values

A type alone rarely expresses the real rule. These are the keywords that carry most of the weight in practice, grouped by what they apply to.

The keywords worth memorising
Applies toKeywordWhat it constrains
stringminLength / maxLengthCharacter count
stringpatternA regular expression the value must match
stringformatA named shape: email, uri, date-time, uuid, ipv4
stringenumOne of a fixed list of permitted values
numberminimum / maximumInclusive bounds
numberexclusiveMinimumA bound the value must exceed
numbermultipleOfDivisibility, useful for currency steps
arrayminItems / maxItemsLength
arrayuniqueItemsNo duplicates
objectrequiredWhich properties must be present
objectadditionalPropertiesWhether unlisted keys are allowed
anyconstExactly one permitted value

One caution about format: by default it is an annotation rather than an assertion, and several validators do not enforce it unless you switch it on. If a malformed address is passing your email check, that is usually why rather than a bug in the regular expression you did not write.

Nesting and arrays of objects

Schemas compose by substitution: anywhere a schema is expected, a whole schema can appear. An array of objects is simply an array whose items is an object schema.

{
  "type": "object",
  "properties": {
    "order": {
      "type": "object",
      "properties": {
        "reference": { "type": "string", "pattern": "^ORD-[0-9]{6}$" },
        "lines": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "properties": {
              "sku": { "type": "string" },
              "quantity": { "type": "integer", "minimum": 1 }
            },
            "required": ["sku", "quantity"]
          }
        }
      },
      "required": ["reference", "lines"]
    }
  },
  "required": ["order"]
}
A nested structure

Reuse with $defs and $ref

The same shape usually appears more than once — an address on a customer and on a delivery, a money amount on every line. Define it once under $defs and point at it. This is not only shorter; it means a change to the rule happens in one place, which is the entire reason schemas beat hand-written checks.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "billing": { "$ref": "#/$defs/address" },
    "delivery": { "$ref": "#/$defs/address" }
  },
  "$defs": {
    "address": {
      "type": "object",
      "properties": {
        "line1": { "type": "string" },
        "postcode": { "type": "string" },
        "country": { "type": "string", "minLength": 2, "maxLength": 2 }
      },
      "required": ["line1", "postcode", "country"]
    }
  }
}
Defined once, referenced twice

Conditional rules

Real contracts have branches: a card payment needs a card number, a transfer needs an account. Three combinators cover almost everything — oneOf for mutually exclusive shapes, anyOf where overlap is acceptable, and if/then when one field decides the rules for another.

{
  "type": "object",
  "properties": {
    "method": { "enum": ["card", "transfer"] },
    "cardNumber": { "type": "string" },
    "iban": { "type": "string" }
  },
  "required": ["method"],
  "if": { "properties": { "method": { "const": "card" } } },
  "then": { "required": ["cardNumber"] },
  "else": { "required": ["iban"] }
}
One field deciding another

The draft version is not a detail

JSON Schema has several published drafts, and they are not interchangeable. Declaring the wrong one in $schema does not usually produce an error — it produces a validation run against the wrong metaschema, which reports success while enforcing far less than you wrote. That is worse than a crash, because nothing tells you it happened.

The array change is the one that bites hardest on upgrade. A draft-07 schema using an array-valued items to describe a tuple means something different under 2020-12, and the document that used to fail now passes.

Validate a document against a schemaDraft-06 through 2020-12, entirely in your browser.

Questions

How do I make a field optional in JSON Schema?

Leave it out of the required array. Every property is optional by default, which is the opposite of what most people expect and the reason a schema that looks strict often accepts an empty object.

What is the difference between $defs and definitions?

They are the same idea under different names. definitions is the draft-07 spelling and $defs replaced it from 2019-09 onward. Use whichever matches the draft you declared in $schema, and do not mix them.

Why does my schema accept a document with a misspelled key?

Because unlisted properties are permitted unless you forbid them. Add additionalProperties: false to the object, and the typo becomes an error instead of an ignored field.

Which draft should I use for a new schema?

2020-12 unless a tool in your pipeline requires otherwise, in which case draft-07 remains the safest widely supported choice. What matters more than the choice is declaring it: a schema without $schema is validated against whatever the library defaults to.