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.
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:
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}`);
}
} 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
Runs on this device, in your browser. 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.
Your run, in this browser
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- A pattern claims one. x-partner-ref matches ^x-, so patternProperties claims it and checks its value, "P-77", against type string.
- 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.
- 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.
- 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.
- 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:
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
Runs on this device, in your browser. 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.
Your run, in this browser
| 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
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:
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
Runs on this device, in your browser. 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.
Your run, in this browser
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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:
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
Runs on this device, in your browser. 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.
Your run, in this browser
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- properties evaluates one. The gift line schema's own properties checks gift_note, a string of 21 characters, and passes.
- 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.
- 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.
- 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
listing_schema()/listingSchema(): a listing has a stringsku, which is required, and may have a booleangift_wrap. Any other member is refused, except members whose names start withx-, which partners add; their values must be strings.slot_schema()/slotSchema(): a delivery slot'sslot_startandslot_endcome together: both or neither.location_schema()/locationSchema(): a location is exactly two numbers, a latitude from -90 to 90 and then a longitude from -180 to 180.gift_line_schema()/giftLineSchema(): a gift line is aLINE(keep it inallOf) that may also have agift_noteof 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which members does additionalProperties apply to?
Those that neither properties nor patternProperties in the same schema object cover. propertyNames checks every member's name.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
additionalPropertiesjudges the members thatpropertiesandpatternPropertiesbeside it leave over, andpropertyNameschecks every name. dependentRequiredanddependentSchemasapply when a member is there, in one direction only, so “both or neither” needs an entry for each member.itemschecks every element after the positions thatprefixItemsdescribes,containswithminContainsandmaxContainscounts matches, anduniqueItemsuses JSON equality, so 1 and 1.0 repeat.prefixItemsrequires no position, so a fixed tuple addsminItemsand"items": false, and the draft-07 array form ofitemsis not 2020-12.additionalPropertiescannot see members named insideallOf, whileunevaluatedPropertiescounts everything evaluated successfully, so composed schemas close withunevaluatedProperties.
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
- JSON Schema Core, draft 2020-12 (applicators and unevaluated locations) (JSON Schema)
- JSON Schema Validation, draft 2020-12 (JSON Schema)
- JSON Schema 2020-12 release notes (JSON Schema)
- JSON Schema 2019-09 release notes (JSON Schema)
- JSON Schema 2020-12 meta-schema (JSON Schema)
- Ajv: strict mode (strictTypes) (Ajv JSON schema validator)
- Ajv: schema language (draft 2019-09 and 2020-12 classes) (Ajv JSON schema validator)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress