Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 3 – JSON Schema 2020-12

Objects and arrays in JSON Schema

Close objects with additionalProperties, tie members together, model lists and tuples with items and prefixItems, and see why allOf needs unevaluatedProperties.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Limit an object's members with additionalProperties, patternProperties and propertyNames
  • Express members that depend on each other with dependentRequired and dependentSchemas
  • Model lists and tuples with items, prefixItems, contains and uniqueItems
  • Choose unevaluatedProperties over additionalProperties when a schema uses allOf

Before you start

What you’ll learn · Before you start

What you will learn

  • Limit an object's members with additionalProperties, patternProperties and propertyNames
  • Express members that depend on each other with dependentRequired and dependentSchemas
  • Model lists and tuples with items, prefixItems, contains and uniqueItems
  • Choose unevaluatedProperties over additionalProperties when a schema uses allOf

Before you start

On this page

Last week an order line said "giftwrap": true instead of "gift_wrap": true, the bookshop’s schema accepted it, and 3 parcels went out unwrapped. The schema had a rule for every member it named and no rule at all for the members it did not, so a misspelt name passed without a word.

You will close an object so that unnamed members fail, while still leaving room for partners’ own members, tie members that only make sense together, describe lists and fixed-position pairs, and see why a closed schema built with allOf refuses valid data until unevaluatedProperties replaces additionalProperties.

additionalProperties refuses the members that properties and patterns leave over

properties gives a rule to each member it names and says nothing about any other member, so an object is open by default. Three keywords change that, each from a different side:

Keyword Applies its subschema to
additionalProperties every member not named in properties and not matched by patternProperties beside it
patternProperties every member whose name matches a regular expression, such as ^x-
propertyNames every member’s name, as a string

"additionalProperties": false uses the boolean schema: One of the JSON Schema schemas true and false, allowed wherever a schema is: true accepts every value, like the empty schema {}, and false refuses every value, which is how "additionalProperties": false refuses left-over members. false, which refuses any value, so every left-over member fails. The program checks three listings against an open schema, a closed one and an extensible one, which also lets partners add members whose names start with x-, as long as the values are strings. This is the validator it uses, the first lesson’s with the keywords of this one added:

The validator with object and array keywords

Python · shapes/minischema.py

"""The first lesson's validator plus the object and array keywords of JSON Schema 2020-12, allOf, and
unevaluatedProperties. check() returns the errors and the member names that were evaluated."""

import json
import re


def kind(value):
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "boolean"
    if isinstance(value, (int, float)):
        return "number"
    if isinstance(value, str):
        return "string"
    return "array" if isinstance(value, list) else "object"


def has_type(value, name):
    if name == "integer":
        return kind(value) == "number" and (isinstance(value, int) or value.is_integer())
    return kind(value) == name


def equal(a, b):
    if kind(a) != kind(b):
        return False
    if kind(a) == "array":
        return len(a) == len(b) and all(equal(x, y) for x, y in zip(a, b))
    if kind(a) == "object":
        return a.keys() == b.keys() and all(equal(a[k], b[k]) for k in a)
    return a == b


def show(value):
    if isinstance(value, float) and value.is_integer():
        value = int(value)
    return json.dumps(value, ensure_ascii=False)


def count(n, noun):
    return f"{n} {noun}{'' if n == 1 else 's'}"


def step(pointer, token):
    return pointer + "/" + str(token).replace("~", "~0").replace("/", "~1")


def apply(schema, value, at, where, refusal):
    """A subschema that may be the boolean schema false, which refuses every value."""
    if schema is False:
        return [(at, where, refusal)], set()
    return check(schema, value, at, where)


def check(schema, value, at="", where=""):
    """(errors, evaluated names): every error of value against schema, and for an object the
    members that keywords here evaluated, including through allOf and dependentSchemas."""
    if schema is True:
        return [], set()
    errors, seen = [], set()
    for keyword, rule in schema.items():
        here = step(where, keyword)
        problem = None
        if keyword == "type":
            names = rule if isinstance(rule, list) else [rule]
            if not any(has_type(value, n) for n in names):
                problem = f"expected {' or '.join(names)}, got {kind(value)} {show(value)}"
        elif keyword == "enum":
            if not any(equal(value, o) for o in rule):
                problem = f"{show(value)} is not one of {', '.join(show(o) for o in rule)}"
        elif keyword == "allOf":  # an in-place applicator: its members count as evaluated
            for i, sub in enumerate(rule):
                sub_errors, sub_seen = check(sub, value, at, step(here, i))
                errors += sub_errors
                seen |= sub_seen if not sub_errors else set()
        elif kind(value) == "string":
            if keyword == "minLength" and len(value) < rule:
                problem = f"{show(value)} has {len(value)} characters, fewer than {rule}"
            elif keyword == "maxLength" and len(value) > rule:
                problem = f"{show(value)} has {len(value)} characters, more than {rule}"
            elif keyword == "pattern" and not re.search(rule, value):
                problem = f"{show(value)} does not match {rule}"
        elif kind(value) == "number":
            if keyword == "minimum" and value < rule:
                problem = f"{show(value)} is below the minimum of {show(rule)}"
            elif keyword == "maximum" and value > rule:
                problem = f"{show(value)} is above the maximum of {show(rule)}"
        elif kind(value) == "object":
            if keyword == "required":
                for name in rule:
                    if name not in value:
                        errors.append((at, here, f"missing required property {show(name)}"))
            elif keyword == "properties":
                for name, sub in rule.items():
                    if name in value:
                        errors += check(sub, value[name], step(at, name), step(here, name))[0]
                        seen.add(name)
            elif keyword == "patternProperties":
                for pattern, sub in rule.items():
                    for name in value:
                        if re.search(pattern, name):
                            errors += check(sub, value[name], step(at, name), step(here, pattern))[0]
                            seen.add(name)
            elif keyword == "additionalProperties":  # sees only the two keywords beside it
                named = schema.get("properties", {})
                patterns = schema.get("patternProperties", {})
                for name in value:
                    if name not in named and not any(re.search(p, name) for p in patterns):
                        errors += apply(rule, value[name], step(at, name), here, f"property {show(name)} is not allowed")[0]
                        seen.add(name)
            elif keyword == "propertyNames":
                for name in value:
                    for _, inner, message in check(rule, name, at, here)[0]:
                        errors.append((at, inner, f"property name {show(name)}: {message}"))
            elif keyword == "minProperties" and len(value) < rule:
                problem = f"{count(len(value), 'member')}, fewer than {rule}"
            elif keyword == "dependentRequired":
                for name, needed in rule.items():
                    for other in needed:
                        if name in value and other not in value:
                            errors.append((at, step(here, name), f"{show(name)} is there, so {show(other)} must be too"))
            elif keyword == "dependentSchemas":  # applies to the whole object when the member is there
                for name, sub in rule.items():
                    if name in value:
                        sub_errors, sub_seen = check(sub, value, at, step(here, name))
                        errors += sub_errors
                        seen |= sub_seen if not sub_errors else set()
        elif kind(value) == "array":
            if keyword == "prefixItems":
                for i, sub in enumerate(rule[: len(value)]):
                    errors += check(sub, value[i], step(at, i), step(here, i))[0]
            elif keyword == "items":  # the items after the ones prefixItems describes
                for i in range(len(schema.get("prefixItems", [])), len(value)):
                    errors += apply(rule, value[i], step(at, i), here, f"item {i} is not allowed")[0]
            elif keyword == "contains":
                matches = sum(1 for item in value if not check(rule, item)[0])
                least, most = schema.get("minContains", 1), schema.get("maxContains")
                if matches < least:
                    errors.append((at, step(where, "minContains" if "minContains" in schema else "contains"),
                                   f"contains matched {count(matches, 'item')}, fewer than {least}"))
                if most is not None and matches > most:
                    errors.append((at, step(where, "maxContains"), f"contains matched {count(matches, 'item')}, more than {most}"))
            elif keyword == "minItems" and len(value) < rule:
                problem = f"{count(len(value), 'item')}, fewer than {rule}"
            elif keyword == "maxItems" and len(value) > rule:
                problem = f"{count(len(value), 'item')}, more than {rule}"
            elif keyword == "uniqueItems" and rule:
                pairs = [(i, j) for j in range(len(value)) for i in range(j) if equal(value[i], value[j])]
                if pairs:
                    problem = f"items {pairs[0][0]} and {pairs[0][1]} are equal"
        if problem:
            errors.append((at, here, problem))
    if kind(value) == "object" and "unevaluatedProperties" in schema:  # runs last, after everything above
        here = step(where, "unevaluatedProperties")
        for name in value:
            if name not in seen:
                refusal = f"property {show(name)} was evaluated by no keyword, so it is not allowed"
                errors += apply(schema["unevaluatedProperties"], value[name], step(at, name), here, refusal)[0]
                seen.add(name)
    return errors, seen


def report(label, schema, value):
    errors = check(schema, value)[0]
    if not errors:
        print(f"{label}: valid")
    for at, where, message in errors:
        place = at if at else '""'  # the empty pointer names the whole document
        print(f"{label}: at {place}, keyword {where}: {message}")

JavaScript · shapes/minischema.mjs

// The first lesson's validator plus the object and array keywords of JSON Schema 2020-12, allOf, and
// unevaluatedProperties. check() returns the errors and the member names that were evaluated.

export function kind(value) {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  return typeof value === 'object' ? 'object' : typeof value;
}

export const hasType = (value, name) => (name === 'integer' ? kind(value) === 'number' && Number.isInteger(value) : kind(value) === name);

export function equal(a, b) {
  if (kind(a) !== kind(b)) return false;
  if (kind(a) === 'array') return a.length === b.length && a.every((x, i) => equal(x, b[i]));
  if (kind(a) === 'object') {
    const keys = Object.keys(a);
    return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]));
  }
  return a === b;
}

export const show = (value) => JSON.stringify(value);
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
export const step = (pointer, token) => `${pointer}/${String(token).replaceAll('~', '~0').replaceAll('/', '~1')}`;
const has = (obj, name) => Object.hasOwn(obj, name);

// A subschema that may be the boolean schema false, which refuses every value.
function apply(schema, value, at, where, refusal) {
  if (schema === false) return [[[at, where, refusal]], new Set()];
  return check(schema, value, at, where);
}

// [errors, evaluated names]: every error of value against schema, and for an object the members
// that keywords here evaluated, including through allOf and dependentSchemas.
export function check(schema, value, at = '', where = '') {
  if (schema === true) return [[], new Set()];
  const errors = [];
  const seen = new Set();
  for (const [keyword, rule] of Object.entries(schema)) {
    const here = step(where, keyword);
    let problem = null;
    if (keyword === 'type') {
      const names = Array.isArray(rule) ? rule : [rule];
      if (!names.some((n) => hasType(value, n))) problem = `expected ${names.join(' or ')}, got ${kind(value)} ${show(value)}`;
    } else if (keyword === 'enum') {
      if (!rule.some((o) => equal(value, o))) problem = `${show(value)} is not one of ${rule.map(show).join(', ')}`;
    } else if (keyword === 'allOf') { // an in-place applicator: its members count as evaluated
      rule.forEach((sub, i) => {
        const [subErrors, subSeen] = check(sub, value, at, step(here, i));
        errors.push(...subErrors);
        if (!subErrors.length) for (const name of subSeen) seen.add(name);
      });
    } else if (kind(value) === 'string') {
      const n = [...value].length;
      if (keyword === 'minLength' && n < rule) problem = `${show(value)} has ${n} characters, fewer than ${rule}`;
      else if (keyword === 'maxLength' && n > rule) problem = `${show(value)} has ${n} characters, more than ${rule}`;
      else if (keyword === 'pattern' && !new RegExp(rule, 'u').test(value)) problem = `${show(value)} does not match ${rule}`;
    } else if (kind(value) === 'number') {
      if (keyword === 'minimum' && value < rule) problem = `${show(value)} is below the minimum of ${show(rule)}`;
      else if (keyword === 'maximum' && value > rule) problem = `${show(value)} is above the maximum of ${show(rule)}`;
    } else if (kind(value) === 'object') {
      if (keyword === 'required') {
        for (const name of rule) if (!has(value, name)) errors.push([at, here, `missing required property ${show(name)}`]);
      } else if (keyword === 'properties') {
        for (const [name, sub] of Object.entries(rule)) {
          if (has(value, name)) {
            errors.push(...check(sub, value[name], step(at, name), step(here, name))[0]);
            seen.add(name);
          }
        }
      } else if (keyword === 'patternProperties') {
        for (const [pattern, sub] of Object.entries(rule)) {
          for (const name of Object.keys(value)) {
            if (new RegExp(pattern, 'u').test(name)) {
              errors.push(...check(sub, value[name], step(at, name), step(here, pattern))[0]);
              seen.add(name);
            }
          }
        }
      } else if (keyword === 'additionalProperties') { // sees only the two keywords beside it
        const named = schema.properties ?? {};
        const patterns = Object.keys(schema.patternProperties ?? {});
        for (const name of Object.keys(value)) {
          if (!has(named, name) && !patterns.some((p) => new RegExp(p, 'u').test(name))) {
            errors.push(...apply(rule, value[name], step(at, name), here, `property ${show(name)} is not allowed`)[0]);
            seen.add(name);
          }
        }
      } else if (keyword === 'propertyNames') {
        for (const name of Object.keys(value)) {
          for (const [, inner, message] of check(rule, name, at, here)[0]) errors.push([at, inner, `property name ${show(name)}: ${message}`]);
        }
      } else if (keyword === 'minProperties' && Object.keys(value).length < rule) {
        problem = `${count(Object.keys(value).length, 'member')}, fewer than ${rule}`;
      } else if (keyword === 'dependentRequired') {
        for (const [name, needed] of Object.entries(rule)) {
          for (const other of needed) {
            if (has(value, name) && !has(value, other)) errors.push([at, step(here, name), `${show(name)} is there, so ${show(other)} must be too`]);
          }
        }
      } else if (keyword === 'dependentSchemas') { // applies to the whole object when the member is there
        for (const [name, sub] of Object.entries(rule)) {
          if (has(value, name)) {
            const [subErrors, subSeen] = check(sub, value, at, step(here, name));
            errors.push(...subErrors);
            if (!subErrors.length) for (const n of subSeen) seen.add(n);
          }
        }
      }
    } else if (kind(value) === 'array') {
      if (keyword === 'prefixItems') {
        rule.slice(0, value.length).forEach((sub, i) => errors.push(...check(sub, value[i], step(at, i), step(here, i))[0]));
      } else if (keyword === 'items') { // the items after the ones prefixItems describes
        for (let i = (schema.prefixItems ?? []).length; i < value.length; i++) {
          errors.push(...apply(rule, value[i], step(at, i), here, `item ${i} is not allowed`)[0]);
        }
      } else if (keyword === 'contains') {
        const matches = value.filter((item) => !check(rule, item)[0].length).length;
        const least = schema.minContains ?? 1;
        const most = schema.maxContains;
        if (matches < least) errors.push([at, step(where, has(schema, 'minContains') ? 'minContains' : 'contains'), `contains matched ${count(matches, 'item')}, fewer than ${least}`]);
        if (most !== undefined && matches > most) errors.push([at, step(where, 'maxContains'), `contains matched ${count(matches, 'item')}, more than ${most}`]);
      } else if (keyword === 'minItems' && value.length < rule) {
        problem = `${count(value.length, 'item')}, fewer than ${rule}`;
      } else if (keyword === 'maxItems' && value.length > rule) {
        problem = `${count(value.length, 'item')}, more than ${rule}`;
      } else if (keyword === 'uniqueItems' && rule) {
        const pairs = [];
        for (let j = 0; j < value.length; j++) for (let i = 0; i < j; i++) if (equal(value[i], value[j])) pairs.push([i, j]);
        if (pairs.length) problem = `items ${pairs[0][0]} and ${pairs[0][1]} are equal`;
      }
    }
    if (problem) errors.push([at, here, problem]);
  }
  if (kind(value) === 'object' && has(schema, 'unevaluatedProperties')) { // runs last, after everything above
    const here = step(where, 'unevaluatedProperties');
    for (const name of Object.keys(value)) {
      if (!seen.has(name)) {
        const refusal = `property ${show(name)} was evaluated by no keyword, so it is not allowed`;
        errors.push(...apply(schema.unevaluatedProperties, value[name], step(at, name), here, refusal)[0]);
        seen.add(name);
      }
    }
  }
  return [errors, seen];
}

export function report(label, schema, value) {
  const errors = check(schema, value)[0];
  if (!errors.length) console.log(`${label}: valid`);
  for (const [at, where, message] of errors) {
    const place = at || '""'; // the empty pointer names the whole document
    console.log(`${label}: at ${place}, keyword ${where}: ${message}`);
  }
}
Open, closed and extensible listings, and member names

Python · shapes/members.py

"""Members a listing schema does not name: allowed, refused, or allowed by a name pattern."""

import json

from minischema import report

OPEN = json.loads("""{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"]}""")
CLOSED = json.loads("""{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"],
  "additionalProperties": false}""")
EXTENSIBLE = json.loads("""{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"],
  "patternProperties": {"^x-": {"type": "string"}}, "additionalProperties": false}""")

DOCUMENTS = {
    "typo": '{"sku": "BK-101", "giftwrap": true}',
    "extension": '{"sku": "BK-101", "gift_wrap": true, "x-partner-ref": "P-77"}',
    "bad extension": '{"sku": "BK-101", "x-partner-ref": 77}',
}
for label, text in DOCUMENTS.items():
    for name, schema in [("open", OPEN), ("closed", CLOSED), ("extensible", EXTENSIBLE)]:
        report(f"{label}, {name}", schema, json.loads(text))

LABELS = json.loads("""{"type": "object",
  "propertyNames": {"pattern": "^[a-z][a-z_]*$"}, "additionalProperties": {"type": "string"}}""")
report("labels", LABELS, json.loads('{"shelf_tag": "new", "Staff-Pick": "yes", "awards": 3}'))

Output

typo, open: valid
typo, closed: at /giftwrap, keyword /additionalProperties: property "giftwrap" is not allowed
typo, extensible: at /giftwrap, keyword /additionalProperties: property "giftwrap" is not allowed
extension, open: valid
extension, closed: at /x-partner-ref, keyword /additionalProperties: property "x-partner-ref" is not allowed
extension, extensible: valid
bad extension, open: valid
bad extension, closed: at /x-partner-ref, keyword /additionalProperties: property "x-partner-ref" is not allowed
bad extension, extensible: at /x-partner-ref, keyword /patternProperties/^x-/type: expected string, got number 77
labels: at "", keyword /propertyNames/pattern: property name "Staff-Pick": "Staff-Pick" does not match ^[a-z][a-z_]*$
labels: at /awards, keyword /additionalProperties/type: expected string, got number 3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 members.py

JavaScript · shapes/members.mjs

// Members a listing schema does not name: allowed, refused, or allowed by a name pattern.
import { report } from './minischema.mjs';

const OPEN = JSON.parse(`{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"]}`);
const CLOSED = JSON.parse(`{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"],
  "additionalProperties": false}`);
const EXTENSIBLE = JSON.parse(`{"type": "object",
  "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}}, "required": ["sku"],
  "patternProperties": {"^x-": {"type": "string"}}, "additionalProperties": false}`);

const DOCUMENTS = {
  typo: '{"sku": "BK-101", "giftwrap": true}',
  extension: '{"sku": "BK-101", "gift_wrap": true, "x-partner-ref": "P-77"}',
  'bad extension': '{"sku": "BK-101", "x-partner-ref": 77}',
};
for (const [label, text] of Object.entries(DOCUMENTS)) {
  for (const [name, schema] of [['open', OPEN], ['closed', CLOSED], ['extensible', EXTENSIBLE]]) {
    report(`${label}, ${name}`, schema, JSON.parse(text));
  }
}

const LABELS = JSON.parse(`{"type": "object",
  "propertyNames": {"pattern": "^[a-z][a-z_]*$"}, "additionalProperties": {"type": "string"}}`);
report('labels', LABELS, JSON.parse('{"shelf_tag": "new", "Staff-Pick": "yes", "awards": 3}'));

Output

typo, open: valid
typo, closed: at /giftwrap, keyword /additionalProperties: property "giftwrap" is not allowed
typo, extensible: at /giftwrap, keyword /additionalProperties: property "giftwrap" is not allowed
extension, open: valid
extension, closed: at /x-partner-ref, keyword /additionalProperties: property "x-partner-ref" is not allowed
extension, extensible: valid
bad extension, open: valid
bad extension, closed: at /x-partner-ref, keyword /additionalProperties: property "x-partner-ref" is not allowed
bad extension, extensible: at /x-partner-ref, keyword /patternProperties/^x-/type: expected string, got number 77
labels: at "", keyword /propertyNames/pattern: property name "Staff-Pick": "Staff-Pick" does not match ^[a-z][a-z_]*$
labels: at /awards, keyword /additionalProperties/type: expected string, got number 3

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node members.mjs

The open schema accepted all three listings, the typo included. The closed one refused the typo, which is what the bookshop needed, but it refused the partner’s x-partner-ref too. The extensible one refused only the typo and the x-partner-ref that held a number. Step through how the members were claimed:

Interactive · stage by stage Which members additionalProperties gets to judge Step through two documents under the extensible schema. Watch which keyword claims each member first.
Which members additionalProperties gets to judge, step 7 of 7. Showing The document's members (sku, giftwrap), properties (claims sku only), patternProperties (claims nothing), additionalProperties (judges giftwrap), The result (error at /giftwrap). In focus: additionalProperties.The document's memberssku, giftwrappropertiesclaims sku onlypatternPropertiesclaims nothingadditionalPropertiesjudges giftwrapThe resulterror at /giftwrapproperty "giftwrap" is not allowed

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Three members. The extension document has three members: sku, gift_wrap and x-partner-ref. The closing keyword will judge only the members that the two keywords beside it leave over.
  2. properties claims two. properties names sku and gift_wrap, so it claims both and checks them: "BK-101" is a string and true is a boolean.
  3. A pattern claims one. x-partner-ref matches ^x-, so patternProperties claims it and checks its value, "P-77", against type string.
  4. Nothing left over. Every member has been claimed, so additionalProperties has nothing to refuse, and the document is valid, as the program printed for extension, extensible.
  5. Valid. Without the pattern, x-partner-ref would have been left over, and false would have refused it: that is the closed schema's result for the same document.
  6. Now the typo. The typo document has sku and giftwrap. properties claims sku; giftwrap is not a name it lists, because the real name has an underscore.
  7. giftwrap is refused. No pattern matches giftwrap, so it is left over, and the subschema false refuses it at /giftwrap. An open schema would have accepted the order and ignored the request for wrapping.

The last two lines show propertyNames. Its subschema checked every name of the labels object, and "Staff-Pick" failed the pattern of lower-case snake names. Its error is reported at the object, because a name has no JSON Pointer of its own. The other error came from additionalProperties with a subschema instead of false, which allows any member as long as its value is a string: "awards": 3 failed.

Write type object beside object keywords

properties, required and the other object keywords apply only to objects, so a schema without "type": "object" accepts the string "BK-101" as a listing. Ajv’s strict mode logs a warning for object keywords without an object type covering them, for this reason.

dependentRequired and dependentSchemas make members come together

A delivery slot needs both its ends, and a coupon is worthless without the discount it gives. Neither rule is about one member: each says that when one member is there, something else must hold. dependentRequired maps a member’s name to the names that must then be there too. dependentSchemas maps it to a whole schema, which the object must then pass. The program checks six orders:

Slots and coupons: members that come together

Python · shapes/together.py

"""Members that must come together: a delivery slot's two ends, and a coupon with its discount."""

import json

from minischema import report

SCHEMA = json.loads("""
{
  "type": "object",
  "properties": {
    "slot_start": {"type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$"},
    "slot_end": {"type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$"},
    "coupon": {"type": "string", "pattern": "^[A-Z0-9]{6}$"},
    "discount_paise": {"type": "integer", "minimum": 1}
  },
  "dependentRequired": {"slot_start": ["slot_end"], "slot_end": ["slot_start"]},
  "dependentSchemas": {
    "coupon": {"required": ["discount_paise"], "properties": {"discount_paise": {"maximum": 50000}}}
  }
}
""")

ORDERS = [
    "{}",
    '{"slot_start": "10:00", "slot_end": "12:00"}',
    '{"slot_start": "10:00"}',
    '{"coupon": "READ25"}',
    '{"coupon": "READ25", "discount_paise": 80000}',
    '{"discount_paise": 80000}',
]
for number, text in enumerate(ORDERS, start=1):
    report(f"order {number}", SCHEMA, json.loads(text))

Output

order 1: valid
order 2: valid
order 3: at "", keyword /dependentRequired/slot_start: "slot_start" is there, so "slot_end" must be too
order 4: at "", keyword /dependentSchemas/coupon/required: missing required property "discount_paise"
order 5: at /discount_paise, keyword /dependentSchemas/coupon/properties/discount_paise/maximum: 80000 is above the maximum of 50000
order 6: valid

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 together.py

JavaScript · shapes/together.mjs

// Members that must come together: a delivery slot's two ends, and a coupon with its discount.
import { report } from './minischema.mjs';

const SCHEMA = JSON.parse(`
{
  "type": "object",
  "properties": {
    "slot_start": {"type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$"},
    "slot_end": {"type": "string", "pattern": "^[0-2][0-9]:[0-5][0-9]$"},
    "coupon": {"type": "string", "pattern": "^[A-Z0-9]{6}$"},
    "discount_paise": {"type": "integer", "minimum": 1}
  },
  "dependentRequired": {"slot_start": ["slot_end"], "slot_end": ["slot_start"]},
  "dependentSchemas": {
    "coupon": {"required": ["discount_paise"], "properties": {"discount_paise": {"maximum": 50000}}}
  }
}
`);

const ORDERS = [
  '{}',
  '{"slot_start": "10:00", "slot_end": "12:00"}',
  '{"slot_start": "10:00"}',
  '{"coupon": "READ25"}',
  '{"coupon": "READ25", "discount_paise": 80000}',
  '{"discount_paise": 80000}',
];
ORDERS.forEach((text, i) => report(`order ${i + 1}`, SCHEMA, JSON.parse(text)));

Output

order 1: valid
order 2: valid
order 3: at "", keyword /dependentRequired/slot_start: "slot_start" is there, so "slot_end" must be too
order 4: at "", keyword /dependentSchemas/coupon/required: missing required property "discount_paise"
order 5: at /discount_paise, keyword /dependentSchemas/coupon/properties/discount_paise/maximum: 80000 is above the maximum of 50000
order 6: valid

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node together.mjs

Order What it shows
1 and 2 neither slot member, or both: valid
3 slot_start alone fails, reported under /dependentRequired/slot_start
4 a coupon without discount_paise fails the required inside dependentSchemas
5 with the coupon there, the dependent schema’s maximum of 50000 applies too
6 the same 80000 without a coupon is valid, because no dependent rule ran

Both keywords work in one direction only. “Both or neither” takes two entries, slot_start needing slot_end and slot_end needing slot_start; with only the first, an order with slot_end alone would pass. Order 6 shows the other side of the same fact: the cap on the discount is part of the coupon rule, so without a coupon nothing checks it.

Draft-07 had one keyword, dependencies, for both jobs: a list of names did what dependentRequired does, and a schema what dependentSchemas does. 2019-09 split it in two, and 2020-12 keeps the split, so a draft-07 schema with dependencies needs its entries moved.

Checkpoint

Check: Closing an object to unnamed members · Members that come together

  1. Question 1 of 2 A schema has properties for sku and gift_wrap and no other object keyword. What happens to a listing with "giftwrap": true?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It passes, and giftwrap is ignored

    An object is open by default. The typo passed the bookshop's schema for this reason, and 3 parcels went out unwrapped.

  2. Question 2 of 2 A schema says "dependentRequired": {"slot_start": ["slot_end"]} and nothing else about slots. Is {"slot_end": "12:00"} valid?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, because the rule runs only when slot_start is there

    Order 3 failed because slot_start was there without slot_end. The reverse case passes unless the schema has the reverse entry.

items checks every element, prefixItems each position, contains a count

Arrays have two shapes. A list holds any number of elements of one kind, such as the lines of an order; a tuple holds a fixed element at each position, such as a latitude and then a longitude. 2020-12 has a keyword for each: prefixItems is an array of schemas, one per position, and items is one schema for every element after them, or for every element when there is no prefixItems. contains asks for elements that match a schema, and uniqueItems forbids repeats. The program checks orders, tags and locations:

Order lines, unique tags and a fixed pair

Python · shapes/arrays.py

"""Lists, unique tags and a fixed-position pair: items, contains, uniqueItems and prefixItems."""

import json

from minischema import report

LINES = json.loads("""
{
  "type": "array",
  "minItems": 1,
  "items": {
    "type": "object",
    "properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
    "required": ["sku", "qty"]
  },
  "contains": {"properties": {"sku": {"pattern": "^GC-"}}, "required": ["sku"]},
  "minContains": 0,
  "maxContains": 2
}
""")
for label, text in [
    ("no lines", "[]"),
    ("a book and a gift card", '[{"sku": "BK-101", "qty": 2}, {"sku": "GC-500", "qty": 1}]'),
    ("three gift cards", '[{"sku": "GC-500", "qty": 1}, {"sku": "GC-500", "qty": 1}, {"sku": "GC-1000", "qty": 1}]'),
    ("a line of 0 copies", '[{"sku": "BK-101", "qty": 0}]'),
]:
    report(label, LINES, json.loads(text))

print()
TAGS = {"type": "array", "uniqueItems": True}
for text in ['["gift", "fragile", "gift"]', "[1, 1.0]", '[{"a": 1, "b": 2}, {"b": 2, "a": 1}]']:
    report(f"tags {text}", TAGS, json.loads(text))

print()
PAIR = json.loads("""
{
  "type": "array",
  "prefixItems": [
    {"type": "number", "minimum": -90, "maximum": 90},
    {"type": "number", "minimum": -180, "maximum": 180}
  ],
  "items": false
}
""")
for text in ["[18.52, 73.86]", "[18.52, 73.86, 560]", "[18.52]"]:
    report(f"location {text}", PAIR, json.loads(text))
report("location [18.52], with minItems 2", {**PAIR, "minItems": 2}, [18.52])

Output

no lines: at "", keyword /minItems: 0 items, fewer than 1
a book and a gift card: valid
three gift cards: at "", keyword /maxContains: contains matched 3 items, more than 2
a line of 0 copies: at /0/qty, keyword /items/properties/qty/minimum: 0 is below the minimum of 1

tags ["gift", "fragile", "gift"]: at "", keyword /uniqueItems: items 0 and 2 are equal
tags [1, 1.0]: at "", keyword /uniqueItems: items 0 and 1 are equal
tags [{"a": 1, "b": 2}, {"b": 2, "a": 1}]: at "", keyword /uniqueItems: items 0 and 1 are equal

location [18.52, 73.86]: valid
location [18.52, 73.86, 560]: at /2, keyword /items: item 2 is not allowed
location [18.52]: valid
location [18.52], with minItems 2: at "", keyword /minItems: 1 item, fewer than 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 arrays.py

JavaScript · shapes/arrays.mjs

// Lists, unique tags and a fixed-position pair: items, contains, uniqueItems and prefixItems.
import { report } from './minischema.mjs';

const LINES = JSON.parse(`
{
  "type": "array",
  "minItems": 1,
  "items": {
    "type": "object",
    "properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
    "required": ["sku", "qty"]
  },
  "contains": {"properties": {"sku": {"pattern": "^GC-"}}, "required": ["sku"]},
  "minContains": 0,
  "maxContains": 2
}
`);
for (const [label, text] of [
  ['no lines', '[]'],
  ['a book and a gift card', '[{"sku": "BK-101", "qty": 2}, {"sku": "GC-500", "qty": 1}]'],
  ['three gift cards', '[{"sku": "GC-500", "qty": 1}, {"sku": "GC-500", "qty": 1}, {"sku": "GC-1000", "qty": 1}]'],
  ['a line of 0 copies', '[{"sku": "BK-101", "qty": 0}]'],
]) {
  report(label, LINES, JSON.parse(text));
}

console.log();
const TAGS = { type: 'array', uniqueItems: true };
for (const text of ['["gift", "fragile", "gift"]', '[1, 1.0]', '[{"a": 1, "b": 2}, {"b": 2, "a": 1}]']) {
  report(`tags ${text}`, TAGS, JSON.parse(text));
}

console.log();
const PAIR = JSON.parse(`
{
  "type": "array",
  "prefixItems": [
    {"type": "number", "minimum": -90, "maximum": 90},
    {"type": "number", "minimum": -180, "maximum": 180}
  ],
  "items": false
}
`);
for (const text of ['[18.52, 73.86]', '[18.52, 73.86, 560]', '[18.52]']) report(`location ${text}`, PAIR, JSON.parse(text));
report('location [18.52], with minItems 2', { ...PAIR, minItems: 2 }, [18.52]);

Output

no lines: at "", keyword /minItems: 0 items, fewer than 1
a book and a gift card: valid
three gift cards: at "", keyword /maxContains: contains matched 3 items, more than 2
a line of 0 copies: at /0/qty, keyword /items/properties/qty/minimum: 0 is below the minimum of 1

tags ["gift", "fragile", "gift"]: at "", keyword /uniqueItems: items 0 and 2 are equal
tags [1, 1.0]: at "", keyword /uniqueItems: items 0 and 1 are equal
tags [{"a": 1, "b": 2}, {"b": 2, "a": 1}]: at "", keyword /uniqueItems: items 0 and 1 are equal

location [18.52, 73.86]: valid
location [18.52, 73.86, 560]: at /2, keyword /items: item 2 is not allowed
location [18.52]: valid
location [18.52], with minItems 2: at "", keyword /minItems: 1 item, fewer than 2

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node arrays.mjs

The bookshop allows at most two gift cards in an order. contains alone would demand at least one, so the schema says "minContains": 0 and "maxContains": 2, which turns contains into a counter. Step through the order that broke it:

Interactive · stage by stage Counting gift cards with contains and maxContains Step through the three lines of one order. items checks each line; contains counts the lines that are gift cards.
Counting gift cards with contains and maxContains, step 5 of 5. Showing Line 0: GC-500 (items: passes; a gift card), Line 1: GC-500 (items: passes; a gift card), Line 2: GC-1000 (items: passes; a gift card), Lines matching contains (3 matches), maxContains (3 is more than 2). In focus: maxContains.Line 0: GC-500items: passes; a gift cardLine 1: GC-500items: passes; a gift cardLine 2: GC-1000items: passes; a gift cardLines matching contains3 matchesmaxContains3 is more than 2contains matched 3 items, more than 2

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. items checks line 0. items applies one schema to every element: an object with a string sku and a qty of at least 1. Line 0 passes it, and its sku starts with GC-, so it also matches contains.
  2. Line 1. Line 1 passes items too, and it is the second gift card. Neither keyword stops at the first element: each looks at all of them.
  3. Line 2. Line 2 is a third gift card. On its own every line is a valid line, so items reports nothing for this order.
  4. The count. contains matched 3 lines. minContains 0 says an order needs no gift card at all, so contains alone cannot fail here; only the count matters.
  5. Over the limit. maxContains 2 allows at most two matches, and there are three, so the order fails at "", the whole array, with keyword /maxContains, as the program printed.

Raise the gift-card limit

Press Edit, change "maxContains": 2 to "maxContains": 3 in LINES, and run it. The order of three gift cards passes, and the other results stay as they were.

uniqueItems compares elements with JSON equality, so [1, 1.0] has a repeat, and so do two objects with the same members in a different order. The location lines show the detail that catches people: prefixItems describes the positions that exist but requires none of them, so [18.52] was valid until the schema also said "minItems": 2. "items": false is what refused the third number.

A draft-07 tuple in a 2020-12 schema

Wrong: "items": [{…latitude…}, {…longitude…}] with "additionalItems": false, the draft-07 way. Right: "prefixItems": [{…}, {…}] with "items": false. In 2020-12 items must be a single schema, so the 2020-12 meta-schema does not accept the old form, and additionalItems is no longer a keyword at all.

Checkpoint

Check: items, prefixItems, contains and uniqueItems

  1. Question 1 of 2 Under the lesson's LINES schema, is an order with two gift cards and one book valid?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Yes, contains matches 2 lines, which maxContains 2 allows

    With minContains 0, contains never fails on its own, and maxContains caps the matches: the order of three gift cards was the one that failed.

  2. Question 2 of 2 A schema has two prefixItems schemas and "items": false, and no minItems. Which value fails?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [18.52, 73.86, 560]

    prefixItems checks the positions that exist and items false refuses any after them. Only minItems makes the positions required.

unevaluatedProperties sees through allOf, additionalProperties does not

The bookshop builds a gift line from the plain line schema: allOf holds LINE, which names sku and qty, and the gift line adds gift_note. To refuse misspelt members, someone added "additionalProperties": false, and every gift line started to fail. The program runs three lines, a good one, a misspelt one and one of 0 copies, against both ways of closing the schema:

A gift line built with allOf, closed two ways

Python · shapes/evaluated.py

"""A gift line built from the plain line schema with allOf, closed two ways."""

import json

from minischema import report

LINE = {
    "type": "object",
    "properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
    "required": ["sku", "qty"],
}
NOTE = {"gift_note": {"type": "string", "maxLength": 120}}
WITH_ADDITIONAL = {"allOf": [LINE], "properties": NOTE, "additionalProperties": False}
WITH_UNEVALUATED = {"allOf": [LINE], "properties": NOTE, "unevaluatedProperties": False}

for label, text in [
    ("good gift line", '{"sku": "BK-101", "qty": 1, "gift_note": "Happy birthday, Meera"}'),
    ("misspelt note", '{"sku": "BK-101", "qty": 1, "gift_nte": "Happy birthday, Meera"}'),
    ("a line of 0 copies", '{"sku": "BK-101", "qty": 0, "gift_note": "Happy birthday, Meera"}'),
]:
    print(label)
    report("  additionalProperties", WITH_ADDITIONAL, json.loads(text))
    report("  unevaluatedProperties", WITH_UNEVALUATED, json.loads(text))

Output

good gift line
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  unevaluatedProperties: valid
misspelt note
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  additionalProperties: at /gift_nte, keyword /additionalProperties: property "gift_nte" is not allowed
  unevaluatedProperties: at /gift_nte, keyword /unevaluatedProperties: property "gift_nte" was evaluated by no keyword, so it is not allowed
a line of 0 copies
  additionalProperties: at /qty, keyword /allOf/0/properties/qty/minimum: 0 is below the minimum of 1
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  unevaluatedProperties: at /qty, keyword /allOf/0/properties/qty/minimum: 0 is below the minimum of 1
  unevaluatedProperties: at /sku, keyword /unevaluatedProperties: property "sku" was evaluated by no keyword, so it is not allowed
  unevaluatedProperties: at /qty, keyword /unevaluatedProperties: property "qty" was evaluated by no keyword, so it is not allowed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 evaluated.py

JavaScript · shapes/evaluated.mjs

// A gift line built from the plain line schema with allOf, closed two ways.
import { report } from './minischema.mjs';

const LINE = {
  type: 'object',
  properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } },
  required: ['sku', 'qty'],
};
const NOTE = { gift_note: { type: 'string', maxLength: 120 } };
const WITH_ADDITIONAL = { allOf: [LINE], properties: NOTE, additionalProperties: false };
const WITH_UNEVALUATED = { allOf: [LINE], properties: NOTE, unevaluatedProperties: false };

for (const [label, text] of [
  ['good gift line', '{"sku": "BK-101", "qty": 1, "gift_note": "Happy birthday, Meera"}'],
  ['misspelt note', '{"sku": "BK-101", "qty": 1, "gift_nte": "Happy birthday, Meera"}'],
  ['a line of 0 copies', '{"sku": "BK-101", "qty": 0, "gift_note": "Happy birthday, Meera"}'],
]) {
  console.log(label);
  report('  additionalProperties', WITH_ADDITIONAL, JSON.parse(text));
  report('  unevaluatedProperties', WITH_UNEVALUATED, JSON.parse(text));
}

Output

good gift line
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  unevaluatedProperties: valid
misspelt note
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  additionalProperties: at /gift_nte, keyword /additionalProperties: property "gift_nte" is not allowed
  unevaluatedProperties: at /gift_nte, keyword /unevaluatedProperties: property "gift_nte" was evaluated by no keyword, so it is not allowed
a line of 0 copies
  additionalProperties: at /qty, keyword /allOf/0/properties/qty/minimum: 0 is below the minimum of 1
  additionalProperties: at /sku, keyword /additionalProperties: property "sku" is not allowed
  additionalProperties: at /qty, keyword /additionalProperties: property "qty" is not allowed
  unevaluatedProperties: at /qty, keyword /allOf/0/properties/qty/minimum: 0 is below the minimum of 1
  unevaluatedProperties: at /sku, keyword /unevaluatedProperties: property "sku" was evaluated by no keyword, so it is not allowed
  unevaluatedProperties: at /qty, keyword /unevaluatedProperties: property "qty" was evaluated by no keyword, so it is not allowed

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node evaluated.mjs

The good line failed with additionalProperties, on sku and qty, the members that only LINE names. The core specification is exact about why: additionalProperties looks only at properties and patternProperties in its own schema object, never inside allOf. unevaluatedProperties, added in 2019-09, looks at every member that any keyword at the same place evaluated successfully, through allOf, $ref and the other applicator: A JSON Schema keyword that applies subschemas to the instance or to parts of it and combines their results, such as allOf, properties or items, as opposed to a keyword that checks the value itself. too, and it runs after all of them. It accepted the good line and refused only gift_nte. Step through what each keyword sees:

Interactive · step by step What each closing keyword sees, through allOf Step through one gift line. Watch which members each closing keyword counts as already evaluated.
What each closing keyword sees, through allOf, step 6 of 6. Showing The gift line (sku, qty, gift_nte), allOf: LINE (evaluated sku, qty), properties (evaluated nothing), unevaluatedProperties (refuses gift_nte). In focus: unevaluatedProperties.gift_notesku, qtyThe gift linesku, qty, gift_nteallOf: LINEevaluated sku, qtypropertiesevaluated nothingunevaluatedPropertiesrefuses gift_nte

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The line. The gift line has three members: sku, qty and gift_note. The schema puts the plain line schema, LINE, in allOf, and names gift_note in its own properties.
  2. allOf evaluates two. LINE checks sku and qty and passes, so its properties keyword has evaluated those two members. That result belongs to LINE, one level down.
  3. properties evaluates one. The gift line schema's own properties checks gift_note, a string of 21 characters, and passes.
  4. additionalProperties looks. additionalProperties sees only properties and patternProperties in the same schema object. To it, only gift_note is named, so sku and qty are left over, and false refuses both.
  5. unevaluatedProperties looks. unevaluatedProperties runs after everything else and counts every member that any keyword evaluated successfully, through allOf too: sku, qty and gift_note. Nothing is left, so the line is valid.
  6. A misspelt member. For the line with gift_nte, no keyword evaluated that member, so unevaluatedProperties refuses it at /gift_nte, and only it, as the program printed.

Only successful results count, which the third line shows. LINE failed on "qty": 0, so the members it named did not count as evaluated, and unevaluatedProperties refused sku and qty as well: one real error brought two more. Fix the first error in such a report and the others go with it. Validators support the keyword from 2019-09 on: Ajv’s default class follows draft-07 and lacks it, while its 2019 and 2020 classes have it.

Choose the way to close an object from your own case:

Interactive · answer and compare How should your schema treat members it does not name? Answer three questions about your documents. Each answer rules ways out, with the run that shows why.
Is the schema built from other schemas with allOf?
Must a misspelt member make the document fail?
Do partners add members of their own, named with x-?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to treat unnamed members, against three needs
Way to close the object A misspelt member fails Works with allOf Partners' x- members pass
Leave it open Refuses: nothing Poor giftwrap was accepted, and the request ignored Good nothing to see through Good every member is accepted
additionalProperties false Refuses: names not in properties Good refused giftwrap at /giftwrap Poor refused sku and qty, which only allOf named Poor refused x-partner-ref
additionalProperties false, with ^x- Refuses: names not listed or x- Good refused giftwrap at /giftwrap Poor still blind to members that allOf names Good x-partner-ref passed as a string
unevaluatedProperties false Refuses: names no keyword evaluated Good refused gift_nte at /gift_nte Good counted sku and qty from allOf Mixed add patternProperties ^x- beside it
How the answers decide
  • “Is the schema built from other schemas with allOf?” Yes, it uses allOf: rules out additionalProperties false and additionalProperties false, with ^x-. additionalProperties sees only properties and patternProperties beside it, so it refused sku and qty, which allOf had named.
  • “Must a misspelt member make the document fail?” Yes, typos must fail: rules out Leave it open. An open object accepted giftwrap, so the order went out without the wrapping it asked for.
  • “Do partners add members of their own, named with x-?” Yes, they add x- members: rules out additionalProperties false and adds a caveat to unevaluatedProperties false. A closing keyword refuses x-partner-ref unless patternProperties ^x- claims it first.

Key idea

additionalProperties judges members against the keywords beside it; unevaluatedProperties judges them against everything that was evaluated, so a schema composed with allOf closes with unevaluatedProperties.

Checkpoint

Check: unevaluatedProperties and allOf

  1. Question 1 of 2 Why did the good gift line fail with "additionalProperties": false?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: additionalProperties sees only the properties beside it, which name gift_note, not sku or qty

    The core specification limits additionalProperties to properties and patternProperties in its own schema object. unevaluatedProperties sees what allOf evaluated.

  2. Question 2 of 2 The gift line with "qty": 0 got three errors under unevaluatedProperties false. Why three, when only qty is wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: LINE failed, so its members did not count as evaluated, and sku and qty were refused as unevaluated

    Fix the first error of such a report and the others go with it: once LINE passes, sku and qty count as evaluated again.

Practise: fix four schemas

The exercise gives you four schemas that are almost right, each with one of this lesson’s mistakes: a closed listing that refuses partners’ members, a slot rule that holds one way, a draft-07 tuple and a gift line closed with the wrong keyword. The tests check 30 documents, and the name of the first failing test points at the schema to fix:

Exercise · Medium · Python, JavaScript

Fix four object and array schemas

Each function in shapes.py (Python) and shapes.mjs (JavaScript) returns a JSON Schema 2020-12 that is almost right. Fix each one, so that the tests' documents are accepted or refused as its rule says:

  1. listing_schema() / listingSchema(): a listing has a string sku, which is required, and may have a boolean gift_wrap. Any other member is refused, except members whose names start with x-, which partners add; their values must be strings.
  2. slot_schema() / slotSchema(): a delivery slot's slot_start and slot_end come together: both or neither.
  3. location_schema() / locationSchema(): a location is exactly two numbers, a latitude from -90 to 90 and then a longitude from -180 to 180.
  4. gift_line_schema() / giftLineSchema(): a gift line is a LINE (keep it in allOf) that may also have a gift_note of at most 120 characters, and no other member.

The tests check 30 documents with the lesson's validator, which is at the top of each test file.

Python · Starter code · shapes.py

LINE = {
    "type": "object",
    "properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
    "required": ["sku", "qty"],
}


def listing_schema():
    return {
        "type": "object",
        "properties": {"sku": {"type": "string"}, "gift_wrap": {"type": "boolean"}},
        "required": ["sku"],
        "additionalProperties": False,
    }


def slot_schema():
    return {
        "type": "object",
        "properties": {"slot_start": {"type": "string"}, "slot_end": {"type": "string"}},
        "dependentRequired": {"slot_start": ["slot_end"]},
    }


def location_schema():
    return {
        "type": "array",
        "items": [
            {"type": "number", "minimum": -90, "maximum": 90},
            {"type": "number", "minimum": -180, "maximum": 180},
        ],
        "additionalItems": False,
    }


def gift_line_schema():
    return {
        "allOf": [LINE],
        "properties": {"gift_note": {"type": "string", "maxLength": 120}},
        "additionalProperties": False,
    }
The sample tests · test_shapes.py
import re

from shapes import gift_line_schema, listing_schema, location_schema, slot_schema


# The lesson's validator (examples/shapes/minischema.py), reduced to the keywords of this exercise.
def kind(value):
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "boolean"
    if isinstance(value, (int, float)):
        return "number"
    if isinstance(value, str):
        return "string"
    return "array" if isinstance(value, list) else "object"


def has_type(value, name):
    if name == "integer":
        return kind(value) == "number" and (isinstance(value, int) or value.is_integer())
    return kind(value) == name


def valid(schema, value):
    """(passes, evaluated member names) for value against schema."""
    if isinstance(schema, bool):
        return schema, set()
    ok, seen = True, set()
    for keyword, rule in schema.items():
        if keyword == "type":
            ok = ok and has_type(value, rule)
        elif keyword == "allOf":
            for sub in rule:
                sub_ok, sub_seen = valid(sub, value)
                ok, seen = ok and sub_ok, seen | (sub_seen if sub_ok else set())
        elif kind(value) == "string":
            if keyword == "maxLength":
                ok = ok and len(value) <= rule
        elif kind(value) == "number":
            if keyword == "minimum":
                ok = ok and value >= rule
            elif keyword == "maximum":
                ok = ok and value <= rule
        elif kind(value) == "object":
            if keyword == "required":
                ok = ok and all(name in value for name in rule)
            elif keyword in ("properties", "patternProperties", "additionalProperties"):
                for name in value:
                    if keyword == "properties":
                        subs = [rule[name]] if name in rule else []
                    elif keyword == "patternProperties":
                        subs = [sub for pattern, sub in rule.items() if re.search(pattern, name)]
                    else:
                        claimed = name in schema.get("properties", {}) or any(
                            re.search(p, name) for p in schema.get("patternProperties", {}))
                        subs = [] if claimed else [rule]
                    for sub in subs:
                        ok = ok and valid(sub, value[name])[0]
                        seen.add(name)
            elif keyword == "dependentRequired":
                ok = ok and all(other in value for name, needed in rule.items() if name in value for other in needed)
        elif kind(value) == "array":
            if keyword == "prefixItems":
                ok = ok and all(valid(sub, item)[0] for sub, item in zip(rule, value))
            elif keyword == "items":
                if isinstance(rule, list):
                    raise ValueError("a list of schemas under items is draft-07; 2020-12 writes positions as prefixItems")
                ok = ok and all(valid(rule, item)[0] for item in value[len(schema.get("prefixItems", [])):])
            elif keyword == "minItems":
                ok = ok and len(value) >= rule
    if kind(value) == "object" and "unevaluatedProperties" in schema:
        for name in value:
            if name not in seen:
                ok = ok and valid(schema["unevaluatedProperties"], value[name])[0]
    return ok, seen


def accepts(schema, value):
    return valid(schema, value)[0]


def test_listing():
    """listing: refuses unknown members except x- members holding strings"""
    for doc in [{"sku": "BK-101"}, {"sku": "BK-101", "gift_wrap": True, "x-partner-ref": "P-77"},
                {"sku": "BK-101", "x-a": "1", "x-b": "2"}]:
        assert accepts(listing_schema(), doc), doc
    for doc in [{"sku": "BK-101", "giftwrap": True}, {"sku": "BK-101", "x-partner-ref": 77}, {"gift_wrap": True},
                {"sku": "BK-101", "X-ref": "P-77"}, {"sku": "BK-101", "gift_wrap": "yes"}]:
        assert not accepts(listing_schema(), doc), doc


def test_slot():
    """slot: slot_start and slot_end come together, both or neither"""
    for doc in [{}, {"slot_start": "10:00", "slot_end": "12:00"}, {"note": "leave at the door"},
                {"slot_start": "10:00", "slot_end": "12:00", "note": "ring twice"}]:
        assert accepts(slot_schema(), doc), doc
    for doc in [{"slot_start": "10:00"}, {"slot_end": "12:00"}]:
        assert not accepts(slot_schema(), doc), doc


def test_location():
    """location: exactly a latitude and a longitude, in range"""
    for doc in [[18.52, 73.86], [-33.87, 151.21], [0, 0], [18.52, -73.86]]:
        assert accepts(location_schema(), doc), doc
    for doc in [[18.52], [], [18.52, 73.86, 560], [95, 10], [10, 190], ["18.52", "73.86"]]:
        assert not accepts(location_schema(), doc), doc


def test_gift_line():
    """gift line: a LINE plus an optional gift_note, nothing else"""
    for doc in [{"sku": "BK-101", "qty": 1}, {"sku": "BK-101", "qty": 2, "gift_note": "Happy birthday"}]:
        assert accepts(gift_line_schema(), doc), doc
    for doc in [{"sku": "BK-101", "qty": 1, "gift_nte": "Happy birthday"}, {"sku": "BK-101", "qty": 0},
                {"sku": "BK-101", "gift_note": "Happy birthday"}, {"sku": "BK-101", "qty": 1, "gift_note": "x" * 121}]:
        assert not accepts(gift_line_schema(), doc), doc

JavaScript · Starter code · shapes.mjs

export const LINE = {
  type: 'object',
  properties: { sku: { type: 'string' }, qty: { type: 'integer', minimum: 1 } },
  required: ['sku', 'qty'],
};

export function listingSchema() {
  return {
    type: 'object',
    properties: { sku: { type: 'string' }, gift_wrap: { type: 'boolean' } },
    required: ['sku'],
    additionalProperties: false,
  };
}

export function slotSchema() {
  return {
    type: 'object',
    properties: { slot_start: { type: 'string' }, slot_end: { type: 'string' } },
    dependentRequired: { slot_start: ['slot_end'] },
  };
}

export function locationSchema() {
  return {
    type: 'array',
    items: [
      { type: 'number', minimum: -90, maximum: 90 },
      { type: 'number', minimum: -180, maximum: 180 },
    ],
    additionalItems: false,
  };
}

export function giftLineSchema() {
  return {
    allOf: [LINE],
    properties: { gift_note: { type: 'string', maxLength: 120 } },
    additionalProperties: false,
  };
}
The sample tests · shapes.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { giftLineSchema, listingSchema, locationSchema, slotSchema } from './shapes.mjs';

// The lesson's validator (examples/shapes/minischema.mjs), reduced to the keywords of this exercise.
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : typeof v);
const hasType = (v, name) => (name === 'integer' ? kind(v) === 'number' && Number.isInteger(v) : kind(v) === name);
const matches = (pattern, text) => new RegExp(pattern, 'u').test(text);

// [passes, evaluated member names] for value against schema.
function valid(schema, value) {
  if (typeof schema === 'boolean') return [schema, new Set()];
  let ok = true;
  const seen = new Set();
  for (const [keyword, rule] of Object.entries(schema)) {
    if (keyword === 'type') ok = ok && hasType(value, rule);
    else if (keyword === 'allOf') {
      for (const sub of rule) {
        const [subOk, subSeen] = valid(sub, value);
        ok = ok && subOk;
        if (subOk) for (const name of subSeen) seen.add(name);
      }
    } else if (kind(value) === 'string') {
      if (keyword === 'maxLength') ok = ok && [...value].length <= rule;
    } else if (kind(value) === 'number') {
      if (keyword === 'minimum') ok = ok && value >= rule;
      else if (keyword === 'maximum') ok = ok && value <= rule;
    } else if (kind(value) === 'object') {
      if (keyword === 'required') ok = ok && rule.every((name) => Object.hasOwn(value, name));
      else if (['properties', 'patternProperties', 'additionalProperties'].includes(keyword)) {
        for (const name of Object.keys(value)) {
          let subs;
          if (keyword === 'properties') subs = Object.hasOwn(rule, name) ? [rule[name]] : [];
          else if (keyword === 'patternProperties') subs = Object.entries(rule).filter(([p]) => matches(p, name)).map(([, sub]) => sub);
          else {
            const claimed = Object.hasOwn(schema.properties ?? {}, name) || Object.keys(schema.patternProperties ?? {}).some((p) => matches(p, name));
            subs = claimed ? [] : [rule];
          }
          for (const sub of subs) {
            ok = ok && valid(sub, value[name])[0];
            seen.add(name);
          }
        }
      } else if (keyword === 'dependentRequired') {
        ok = ok && Object.entries(rule).every(([name, needed]) => !Object.hasOwn(value, name) || needed.every((o) => Object.hasOwn(value, o)));
      }
    } else if (kind(value) === 'array') {
      if (keyword === 'prefixItems') ok = ok && rule.every((sub, i) => i >= value.length || valid(sub, value[i])[0]);
      else if (keyword === 'items') {
        if (Array.isArray(rule)) throw new Error('a list of schemas under items is draft-07; 2020-12 writes positions as prefixItems');
        ok = ok && value.slice((schema.prefixItems ?? []).length).every((item) => valid(rule, item)[0]);
      } else if (keyword === 'minItems') ok = ok && value.length >= rule;
    }
  }
  if (kind(value) === 'object' && Object.hasOwn(schema, 'unevaluatedProperties')) {
    for (const name of Object.keys(value)) if (!seen.has(name)) ok = ok && valid(schema.unevaluatedProperties, value[name])[0];
  }
  return [ok, seen];
}

const accepts = (schema, value) => valid(schema, value)[0];
const text = (doc) => JSON.stringify(doc);

test('listing: refuses unknown members except x- members holding strings', () => {
  for (const doc of [{ sku: 'BK-101' }, { sku: 'BK-101', gift_wrap: true, 'x-partner-ref': 'P-77' }, { sku: 'BK-101', 'x-a': '1', 'x-b': '2' }]) {
    assert.ok(accepts(listingSchema(), doc), text(doc));
  }
  for (const doc of [{ sku: 'BK-101', giftwrap: true }, { sku: 'BK-101', 'x-partner-ref': 77 }, { gift_wrap: true }, { sku: 'BK-101', 'X-ref': 'P-77' }, { sku: 'BK-101', gift_wrap: 'yes' }]) {
    assert.ok(!accepts(listingSchema(), doc), text(doc));
  }
});

test('slot: slot_start and slot_end come together, both or neither', () => {
  for (const doc of [{}, { slot_start: '10:00', slot_end: '12:00' }, { note: 'leave at the door' }, { slot_start: '10:00', slot_end: '12:00', note: 'ring twice' }]) {
    assert.ok(accepts(slotSchema(), doc), text(doc));
  }
  for (const doc of [{ slot_start: '10:00' }, { slot_end: '12:00' }]) assert.ok(!accepts(slotSchema(), doc), text(doc));
});

test('location: exactly a latitude and a longitude, in range', () => {
  for (const doc of [[18.52, 73.86], [-33.87, 151.21], [0, 0], [18.52, -73.86]]) assert.ok(accepts(locationSchema(), doc), text(doc));
  for (const doc of [[18.52], [], [18.52, 73.86, 560], [95, 10], [10, 190], ['18.52', '73.86']]) {
    assert.ok(!accepts(locationSchema(), doc), text(doc));
  }
});

test('gift line: a LINE plus an optional gift_note, nothing else', () => {
  for (const doc of [{ sku: 'BK-101', qty: 1 }, { sku: 'BK-101', qty: 2, gift_note: 'Happy birthday' }]) assert.ok(accepts(giftLineSchema(), doc), text(doc));
  for (const doc of [{ sku: 'BK-101', qty: 1, gift_nte: 'Happy birthday' }, { sku: 'BK-101', qty: 0 }, { sku: 'BK-101', gift_note: 'Happy birthday' }, { sku: 'BK-101', qty: 1, gift_note: 'x'.repeat(121) }]) {
    assert.ok(!accepts(giftLineSchema(), doc), text(doc));
  }
});
A hint

Each schema has one of the mistakes the lesson ran into. Look at which test fails first: a member that the rule allows but the schema refuses points at additionalProperties; a rule that holds only one way points at dependentRequired; an array of schemas under items is how draft-07 wrote positions, and 2020-12 calls that prefixItems; and a closed schema that also uses allOf needs the keyword that sees through it.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then try the self-check on closing objects, dependent members, arrays and evaluation. It opens with the idea your answers show is weakest:

Practice that adapts to you

Practise this lesson

13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Under the extensible schema, which member of {"sku": "BK-101", "x-ref": "P-1", "notes": "fragile"} fails?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: notes

    properties and patternProperties claim members first; additionalProperties judges only what remains.

  2. Question 2 of 13 What does propertyNames check?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Every member's name, as a string

    A name has no JSON Pointer of its own, so propertyNames errors are reported at the object that holds the name.

  3. Question 3 of 13 The labels object uses "additionalProperties": {"type": "string"} with no properties. What does that rule mean?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Any member may appear, as long as its value is a string

    additionalProperties takes any schema, not only false. With a schema, it is a rule for the values of the members nobody else claims.

  4. Question 4 of 13 When does a subschema under dependentSchemas apply, and to what?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: When its member is there, to the whole object

    dependentSchemas is conditional on presence. Order 6, a discount without a coupon, passed because the coupon rule never ran.

  5. Question 5 of 13 Which dependentRequired says that phone and phone_country come together, both or neither?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: {"phone": ["phone_country"], "phone_country": ["phone"]}

    dependentRequired is a list of one-way rules; a symmetric rule is two of them.

  6. Question 6 of 13 A draft-07 schema has "dependencies": {"coupon": ["discount_paise"], "gift_wrap": {"required": ["gift_note"]}}. How is it written in 2020-12?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: dependentRequired for coupon, and dependentSchemas for gift_wrap

    dependencies did two jobs. The split gives each job its own keyword, which validators and readers understand without looking at the value's type.

  7. Question 7 of 13 Without minContains, what does "contains" demand of an array?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: At least one element that matches its schema

    The lesson's schema set minContains to 0, so that an order needs no gift card, and used maxContains for the cap.

  8. Question 8 of 13 Is [{"a": 1, "b": 2}, {"b": 2, "a": 1}] valid against {"uniqueItems": true}?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: No, the two objects are equal

    Two values are equal when they have the same type and value; for objects, the same members with equal values, in any order. 1 and 1.0 are equal too.

  9. Question 9 of 13 A schema has "prefixItems": [{"type": "string"}] and "items": {"type": "integer"}. Which array fails?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: ["a", "b"]

    items applies to the elements after the positions that prefixItems describes, which is what additionalItems used to do in draft-07.

  10. Question 10 of 13 Which 2020-12 schema accepts exactly a latitude and then a longitude, and nothing else?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: prefixItems with the two schemas, "items": false and "minItems": 2

    Each keyword closes one gap: prefixItems checks positions, items false stops extra elements, and minItems makes the positions compulsory.

  11. Question 11 of 13 When is unevaluatedProperties applied, compared with the other keywords at the same place?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: After all of them, because it needs their results

    The lesson's validators check it last, after the loop over the other keywords, for this reason.

  12. Question 12 of 13 A schema with unevaluatedProperties is checked with Ajv's default class. What should you change?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Use Ajv's 2019 or 2020 class, because the default class follows draft-07

    For a 2020-12 schema, Ajv's documentation says to import the class from ajv/dist/2020.

  13. Question 13 of 13 The gift line names LINE through a $ref instead of allOf, and also closes with unevaluatedProperties false. Are sku and qty accepted?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Yes, because unevaluatedProperties counts what the $ref target evaluated

    unevaluatedProperties sees every successful result at the same place, whether it came from allOf, $ref or another applicator.

Interview questions

Warm-up (fresher to 2–5 years): how do prefixItems and items divide an array between them in 2020-12?

prefixItems is an array of schemas, one per position, for tuples: the first element must pass the first schema, the second the second, and so on. items is a single schema for every element after those positions, or for every element when there is no prefixItems. prefixItems does not require the positions to exist, so a fixed-length tuple also needs minItems, and "items": false refuses any element after them. Draft-07 wrote positions as an array under items and the rest under additionalItems; 2020-12 renamed both.

Two harder questions follow, about a composed schema that refuses valid requests and about members that only make sense together. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    After a refactor, a request schema became an allOf of a shared base schema plus its own properties, closed with "additionalProperties": false. Every valid request is now refused with "property is not allowed" errors. Explain the cause and the fix, and what you would do for a service whose validator supports only draft-07.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    An invoice may have a tax_id. When it does, company_name and billing_address must be present, and billing_address must include a postcode. Without a tax_id, both may still appear. Which JSON Schema 2020-12 keywords express this, and which mistakes would your tests look for?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which members does additionalProperties apply to?

    Those that neither properties nor patternProperties in the same schema object cover. propertyNames checks every member's name.

  2. What do dependentRequired and dependentSchemas do?

    When a given member is present, the first requires other members and the second applies a whole subschema. Each works one way only, so "both or neither" needs an entry for each member.

  3. How do prefixItems and items share an array in 2020-12?

    prefixItems has one schema per position from the start; items applies to every element after those positions.

  4. How do you allow exactly a two-element tuple in 2020-12?

    prefixItems with two schemas, minItems of 2 so both must exist, and "items": false to refuse anything after them.

  5. Does uniqueItems treat 1 and 1.0 as different values?

    No. It compares by JSON equality, so 1 and 1.0 count as the same value repeated.

  6. What do contains, minContains and maxContains check?

    contains asks for elements that match a schema, and minContains and maxContains set how many matches are allowed.

  7. Why does a schema composed with allOf close with unevaluatedProperties?

    additionalProperties cannot see members named inside the allOf branches and refuses them; unevaluatedProperties counts every member a passing branch evaluated.

Key takeaways

  • An object is open unless a keyword closes it: additionalProperties judges the members that properties and patternProperties beside it leave over, and propertyNames checks every name.
  • dependentRequired and dependentSchemas apply when a member is there, in one direction only, so “both or neither” needs an entry for each member.
  • items checks every element after the positions that prefixItems describes, contains with minContains and maxContains counts matches, and uniqueItems uses JSON equality, so 1 and 1.0 repeat.
  • prefixItems requires no position, so a fixed tuple adds minItems and "items": false, and the draft-07 array form of items is not 2020-12.
  • additionalProperties cannot see members named inside allOf, while unevaluatedProperties counts everything evaluated successfully, so composed schemas close with unevaluatedProperties.

Terms in this lesson

applicator
A JSON Schema keyword that applies subschemas to the instance or to parts of it and combines their results, such as allOf, properties or items, as opposed to a keyword that checks the value itself.
boolean schema
One of the JSON Schema schemas true and false, allowed wherever a schema is: true accepts every value, like the empty schema {}, and false refuses every value, which is how "additionalProperties": false refuses left-over members.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.