Data Formats (JSON, YAML, TOML, XML, CSV) Module 3 – JSON Schema 2020-12
Drafts, OpenAPI and code from JSON Schema
Migrate a draft-07 schema to 2020-12 and prove nothing changed, rewrite OpenAPI 3.0 schemas for 3.1 and 3.2, and generate types without losing rules.
What you will learn
- Explain which changes between draft-04 and 2020-12 alter what a schema means
- Migrate a draft-07 schema to 2020-12 and prove that its results did not change
- Convert OpenAPI 3.0 Schema Objects to the JSON Schema 2020-12 of OpenAPI 3.1 and 3.2
- Generate types from a schema and keep the schema the source of truth
Before you start
What you’ll learn · Before you start
What you will learn
- Explain which changes between draft-04 and 2020-12 alter what a schema means
- Migrate a draft-07 schema to 2020-12 and prove that its results did not change
- Convert OpenAPI 3.0 Schema Objects to the JSON Schema 2020-12 of OpenAPI 3.1 and 3.2
- Generate types from a schema and keep the schema the source of truth
Before you start
On this page
A team moved its order schema from draft-07 to 2020-12 by renaming keywords, and 8 of 108 test orders that used to pass began to fail, every one of them a valid order. Nothing in the rename was wrong; one keyword that draft-07 had always ignored started to apply.
You will learn which draft changed what a schema means, migrate a draft-07 schema and prove that every result stayed the same, rewrite OpenAPI 3.0 schemas for the JSON Schema that OpenAPI 3.1 and 3.2 use, and generate types from a schema without forgetting the rules that types cannot hold.
Each draft renamed or split keywords, and 2020-12 is still current
This lesson follows five drafts, draft-04, draft-06, draft-07, 2019-09 and 2020-12, and each changed the meaning of
some keywords, not only their names. A schema says which one it follows in $schema: http://json-schema.org/draft-07/schema# for draft-07, and
https://json-schema.org/draft/2020-12/schema for 2020-12, which the JSON Schema project lists as the current version.
Walk through the changes that make an old schema mean something else:
- draft-04
The draft many old schemas still name boolean exclusive limits
Its exclusiveMinimum and exclusiveMaximum are true or false beside minimum and maximum, a resource is named with id, and 1.0 does not count as an integer.
- draft-06
Numbers for exclusive limits 1.0 is an integer
id becomes $id, exclusiveMinimum and exclusiveMaximum become numbers of their own, 1.0 becomes an integer, true and false become schemas anywhere, and const, contains, propertyNames and examples arrive.
- draft-07
Conditions if, then, else
if, then and else arrive, with readOnly, writeOnly and $comment. Keywords beside a $ref are still ignored, and dependencies still does two jobs.
- 2019-09
Vocabularies and evaluation $ref siblings apply
definitions becomes $defs, dependencies splits into dependentRequired and dependentSchemas, $anchor takes the place of fragments in $id, keywords beside $ref now apply, format stops checking by default, and unevaluatedProperties and $vocabulary arrive.
- 2020-12
The current version prefixItems
prefixItems takes the array form of items and items takes over from additionalItems, $dynamicRef and $dynamicAnchor replace the recursive keywords, and format is split into an annotation vocabulary and an assertion one.
- Next
A stable specification
The JSON Schema team publishes on its own and works with the IETF, where the first draft of a JSON Schema standard is little more than an edit of 2020-12. The team's top concern is that upgrading stays safe.
The timeline has 6 events, from draft-04 to Next.
Three of those changes alter results silently, without any error to warn you: 1.0 became an integer in draft-06, keywords
beside $ref began to apply in 2019-09, and format stopped checking by default in the same version. The others are
renames, and a migration must still carry them out: the 2020-12 meta-schema keeps definitions and dependencies only
as deprecated entries, and the array form of items is not valid 2020-12, where items must be one schema.
| Old form | 2020-12 form | Since |
|---|---|---|
"exclusiveMinimum": true beside "minimum": 0 |
"exclusiveMinimum": 0 |
draft-06 |
definitions |
$defs |
2019-09 |
dependencies |
dependentRequired and dependentSchemas |
2019-09 |
"$id": "#name" for a plain name |
"$anchor": "name" |
2019-09 |
"items": [a, b] with additionalItems |
"prefixItems": [a, b] with items |
2020-12 |
$recursiveRef and $recursiveAnchor |
$dynamicRef and $dynamicAnchor |
2020-12 |
Work on the next version is under way. The JSON Schema team publishes on its own and works with the IETF, where an initial draft is little more than an edit of 2020-12, and the team says upgrade compatibility is its first concern.
Checkpoint
Check: What each draft changed
Migrate a draft-07 schema, then prove its results did not change
A migration is correct when every document gets the same result before and after it, so test that directly. The program migrates a draft-07 order schema with five rules, then checks every combination of 108 orders: through the old schema under draft-07 rules and through the new one under 2020-12 rules.
Python · drafts/migrate.py
"""A draft-07 order schema migrated to 2020-12, then every combination of 108 orders checked under both drafts' rules."""
import copy
import json
import re
OLD = json.loads("""
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"definitions": {"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"}},
"properties": {
"sku": {"$ref": "#/definitions/sku", "maxLength": 3},
"location": {"type": "array", "items": [{"type": "number"}, {"type": "number"}], "additionalItems": false},
"coupon": {"type": "string"},
"gift_wrap": {"type": "boolean"}
},
"required": ["sku"],
"dependencies": {"coupon": ["discount_paise"], "gift_wrap": {"required": ["gift_note"]}}
}
""")
ONE = ("items", "not", "if", "then", "else", "additionalProperties", "contains", "propertyNames")
def migrate(schema):
"""The 2020-12 form of a draft-07 schema with the same results: keywords draft-07 ignored are dropped."""
if not isinstance(schema, dict):
return schema
if "$ref" in schema: # draft-07 applies no other keyword beside $ref, so the rules beside it are dropped
new = migrate({k: v for k, v in schema.items() if k in ("$schema", "definitions")}) # these hold no rule
new["$ref"] = schema["$ref"].replace("#/definitions/", "#/$defs/", 1)
return new
new = {}
for key, value in schema.items():
if key == "$schema":
new[key] = "https://json-schema.org/draft/2020-12/schema"
elif key == "definitions":
new["$defs"] = {name: migrate(sub) for name, sub in value.items()}
elif key == "dependencies": # a list of names, or a schema
for name, rule in value.items():
target = "dependentRequired" if isinstance(rule, list) else "dependentSchemas"
new.setdefault(target, {})[name] = list(rule) if isinstance(rule, list) else migrate(rule)
elif key == "items" and isinstance(value, list): # positions, and what may follow them
new["prefixItems"] = [migrate(sub) for sub in value]
if "additionalItems" in schema:
new["items"] = migrate(schema["additionalItems"])
elif key == "additionalItems":
continue # moved above, or ignored by draft-07 because items was not a list
elif key in ("properties", "patternProperties"):
new[key] = {name: migrate(sub) for name, sub in value.items()}
elif key in ("allOf", "anyOf", "oneOf"):
new[key] = [migrate(sub) for sub in value]
elif key in ONE:
new[key] = migrate(value)
else:
new[key] = copy.deepcopy(value)
return new
def kind(value):
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
return {str: "string", list: "array", dict: "object"}.get(type(value), "null")
def valid(schema, value, root, draft):
"""Whether value passes schema under the rules of draft 7 or draft 2020, for the keywords of this lesson."""
if isinstance(schema, bool):
return schema
if "$ref" in schema:
target = root
for token in schema["$ref"][2:].split("/"):
target = target[token]
if not valid(target, value, root, draft):
return False
if draft == 7:
return True # draft-07: the keywords beside $ref are never looked at
for key, rule in schema.items():
if key == "type" and kind(value) != rule:
return False
if kind(value) == "string":
if key == "pattern" and not re.search(rule, value):
return False
if key == "maxLength" and len(value) > rule:
return False
if kind(value) == "object":
if key == "required" and any(n not in value for n in rule):
return False
if key == "properties" and not all(valid(s, value[n], root, draft) for n, s in rule.items() if n in value):
return False
if draft == 7 and key == "dependencies":
for name, dep in rule.items():
if name in value and not (all(n in value for n in dep) if isinstance(dep, list)
else valid(dep, value, root, draft)):
return False
if draft == 2020 and key == "dependentRequired":
if any(n in value and any(m not in value for m in need) for n, need in rule.items()):
return False
if draft == 2020 and key == "dependentSchemas":
if not all(valid(s, value, root, draft) for n, s in rule.items() if n in value):
return False
if kind(value) == "array":
if draft == 7 and key == "items" and isinstance(rule, list):
extra = schema.get("additionalItems", True)
if not all(valid(s, v, root, draft) for s, v in zip(rule, value)):
return False
if not all(valid(extra, v, root, draft) for v in value[len(rule):]):
return False
if draft == 2020 and key == "prefixItems":
if not all(valid(s, v, root, draft) for s, v in zip(rule, value)):
return False
if draft == 2020 and key == "items":
if not all(valid(rule, v, root, draft) for v in value[len(schema.get("prefixItems", [])):]):
return False
return True
NEW = migrate(OLD)
print(json.dumps(NEW, indent=2))
ORDERS = []
for sku in ["BK-101", "bk-101", "BK-1011"]:
for location in [[18.52, 73.86], [18.52, 73.86, 560], [18.52], ["18.52", 73.86]]:
for coupon in [{}, {"coupon": "READ25"}, {"coupon": "READ25", "discount_paise": 5000}]:
for gift in [{}, {"gift_wrap": True}, {"gift_wrap": True, "gift_note": "For Meera"}]:
ORDERS.append({"sku": sku, "location": location, **coupon, **gift})
same = sum(valid(OLD, o, OLD, 7) == valid(NEW, o, NEW, 2020) for o in ORDERS)
print()
print(f"draft-07 rules on the old schema, 2020-12 rules on the new: {same} of {len(ORDERS)} orders get the same result")
KEPT = copy.deepcopy(NEW)
KEPT["properties"]["sku"]["maxLength"] = 3 # a migration that keeps the keyword draft-07 never applied
changed = [o for o in ORDERS if valid(OLD, o, OLD, 7) != valid(KEPT, o, KEPT, 2020)]
print(f'keeping "maxLength": 3 beside the $ref: {len(changed)} of {len(ORDERS)} orders change, for example')
print(" ", json.dumps(changed[0], separators=(",", ":")), "was valid and is now refused") Output
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"sku": {
"type": "string",
"pattern": "^BK-[0-9]{3}$"
}
},
"properties": {
"sku": {
"$ref": "#/$defs/sku"
},
"location": {
"type": "array",
"prefixItems": [
{
"type": "number"
},
{
"type": "number"
}
],
"items": false
},
"coupon": {
"type": "string"
},
"gift_wrap": {
"type": "boolean"
}
},
"required": [
"sku"
],
"dependentRequired": {
"coupon": [
"discount_paise"
]
},
"dependentSchemas": {
"gift_wrap": {
"required": [
"gift_note"
]
}
}
}
draft-07 rules on the old schema, 2020-12 rules on the new: 108 of 108 orders get the same result
keeping "maxLength": 3 beside the $ref: 8 of 108 orders change, for example
{"sku":"BK-101","location":[18.52,73.86]} was valid and is now refused
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 migrate.py
JavaScript · drafts/migrate.mjs
// A draft-07 order schema migrated to 2020-12, then every combination of 108 orders checked under both drafts' rules.
const OLD = JSON.parse(`
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"definitions": {"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"}},
"properties": {
"sku": {"$ref": "#/definitions/sku", "maxLength": 3},
"location": {"type": "array", "items": [{"type": "number"}, {"type": "number"}], "additionalItems": false},
"coupon": {"type": "string"},
"gift_wrap": {"type": "boolean"}
},
"required": ["sku"],
"dependencies": {"coupon": ["discount_paise"], "gift_wrap": {"required": ["gift_note"]}}
}
`);
const ONE = ['items', 'not', 'if', 'then', 'else', 'additionalProperties', 'contains', 'propertyNames'];
const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const copy = (x) => JSON.parse(JSON.stringify(x));
// The 2020-12 form of a draft-07 schema with the same results: keywords draft-07 ignored are dropped.
function migrate(schema) {
if (!isObject(schema)) return schema;
if (Object.hasOwn(schema, '$ref')) { // draft-07 applies no other keyword beside $ref, so the rules beside it are dropped
const kept = Object.entries(schema).filter(([key]) => key === '$schema' || key === 'definitions'); // these hold no rule
return { ...migrate(Object.fromEntries(kept)), $ref: schema.$ref.replace('#/definitions/', '#/$defs/') };
}
const next = {};
for (const [key, value] of Object.entries(schema)) {
if (key === '$schema') next[key] = 'https://json-schema.org/draft/2020-12/schema';
else if (key === 'definitions') next.$defs = Object.fromEntries(Object.entries(value).map(([n, s]) => [n, migrate(s)]));
else if (key === 'dependencies') { // a list of names, or a schema
for (const [name, rule] of Object.entries(value)) {
const target = Array.isArray(rule) ? 'dependentRequired' : 'dependentSchemas';
(next[target] ??= {})[name] = Array.isArray(rule) ? [...rule] : migrate(rule);
}
} else if (key === 'items' && Array.isArray(value)) { // positions, and what may follow them
next.prefixItems = value.map(migrate);
if (Object.hasOwn(schema, 'additionalItems')) next.items = migrate(schema.additionalItems);
} else if (key === 'additionalItems') {
continue; // moved above, or ignored by draft-07 because items was not a list
} else if (key === 'properties' || key === 'patternProperties') {
next[key] = Object.fromEntries(Object.entries(value).map(([n, s]) => [n, migrate(s)]));
} else if (['allOf', 'anyOf', 'oneOf'].includes(key)) next[key] = value.map(migrate);
else if (ONE.includes(key)) next[key] = migrate(value);
else next[key] = copy(value);
}
return next;
}
function kind(value) {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
}
// Whether value passes schema under the rules of draft 7 or draft 2020, for the keywords of this lesson.
function valid(schema, value, root, draft) {
if (typeof schema === 'boolean') return schema;
if (Object.hasOwn(schema, '$ref')) {
let target = root;
for (const token of schema.$ref.slice(2).split('/')) target = target[token];
if (!valid(target, value, root, draft)) return false;
if (draft === 7) return true; // draft-07: the keywords beside $ref are never looked at
}
const has = (o, n) => Object.hasOwn(o, n);
for (const [key, rule] of Object.entries(schema)) {
if (key === 'type' && kind(value) !== rule) return false;
if (kind(value) === 'string') {
if (key === 'pattern' && !new RegExp(rule, 'u').test(value)) return false;
if (key === 'maxLength' && [...value].length > rule) return false;
}
if (kind(value) === 'object') {
if (key === 'required' && rule.some((n) => !has(value, n))) return false;
if (key === 'properties' && !Object.entries(rule).every(([n, s]) => !has(value, n) || valid(s, value[n], root, draft))) return false;
if (draft === 7 && key === 'dependencies') {
for (const [name, dep] of Object.entries(rule)) {
if (has(value, name) && !(Array.isArray(dep) ? dep.every((n) => has(value, n)) : valid(dep, value, root, draft))) return false;
}
}
if (draft === 2020 && key === 'dependentRequired' && Object.entries(rule).some(([n, need]) => has(value, n) && need.some((m) => !has(value, m)))) return false;
if (draft === 2020 && key === 'dependentSchemas' && !Object.entries(rule).every(([n, s]) => !has(value, n) || valid(s, value, root, draft))) return false;
}
if (kind(value) === 'array') {
if (draft === 7 && key === 'items' && Array.isArray(rule)) {
const extra = has(schema, 'additionalItems') ? schema.additionalItems : true;
if (!rule.every((s, i) => i >= value.length || valid(s, value[i], root, draft))) return false;
if (!value.slice(rule.length).every((v) => valid(extra, v, root, draft))) return false;
}
if (draft === 2020 && key === 'prefixItems' && !rule.every((s, i) => i >= value.length || valid(s, value[i], root, draft))) return false;
if (draft === 2020 && key === 'items' && !value.slice((schema.prefixItems ?? []).length).every((v) => valid(rule, v, root, draft))) return false;
}
}
return true;
}
const NEW = migrate(OLD);
console.log(JSON.stringify(NEW, null, 2));
const ORDERS = [];
for (const sku of ['BK-101', 'bk-101', 'BK-1011']) {
for (const location of [[18.52, 73.86], [18.52, 73.86, 560], [18.52], ['18.52', 73.86]]) {
for (const coupon of [{}, { coupon: 'READ25' }, { coupon: 'READ25', discount_paise: 5000 }]) {
for (const gift of [{}, { gift_wrap: true }, { gift_wrap: true, gift_note: 'For Meera' }]) {
ORDERS.push({ sku, location, ...coupon, ...gift });
}
}
}
}
const same = ORDERS.filter((o) => valid(OLD, o, OLD, 7) === valid(NEW, o, NEW, 2020)).length;
console.log();
console.log(`draft-07 rules on the old schema, 2020-12 rules on the new: ${same} of ${ORDERS.length} orders get the same result`);
const KEPT = copy(NEW);
KEPT.properties.sku.maxLength = 3; // a migration that keeps the keyword draft-07 never applied
const changed = ORDERS.filter((o) => valid(OLD, o, OLD, 7) !== valid(KEPT, o, KEPT, 2020));
console.log(`keeping "maxLength": 3 beside the $ref: ${changed.length} of ${ORDERS.length} orders change, for example`);
console.log(' ', JSON.stringify(changed[0]), 'was valid and is now refused'); Output
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"sku": {
"type": "string",
"pattern": "^BK-[0-9]{3}$"
}
},
"properties": {
"sku": {
"$ref": "#/$defs/sku"
},
"location": {
"type": "array",
"prefixItems": [
{
"type": "number"
},
{
"type": "number"
}
],
"items": false
},
"coupon": {
"type": "string"
},
"gift_wrap": {
"type": "boolean"
}
},
"required": [
"sku"
],
"dependentRequired": {
"coupon": [
"discount_paise"
]
},
"dependentSchemas": {
"gift_wrap": {
"required": [
"gift_note"
]
}
}
}
draft-07 rules on the old schema, 2020-12 rules on the new: 108 of 108 orders get the same result
keeping "maxLength": 3 beside the $ref: 8 of 108 orders change, for example
{"sku":"BK-101","location":[18.52,73.86]} was valid and is now refused
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node migrate.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
All 108 orders got the same result. The last two lines show the failure of the opening: a migration that keeps
"maxLength": 3 beside the $ref changes 8 orders, all of them valid ones such as {"sku":"BK-101", …}, because
draft-07 never looked at keywords beside a $ref and 2020-12 does. A faithful migration drops such keywords and keeps
only $schema and definitions beside a $ref, because they hold no rule and the reference may point into
definitions. A team that wanted a dropped rule puts it in an allOf with the reference, as a change of its own, with
its own tests. Step through the rules:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The dialect. The old schema names http://json-schema.org/draft-07/schema#. The new one names https://json-schema.org/draft/2020-12/schema, so validators apply the 2020-12 meanings.
- $defs. definitions becomes $defs, and the reference #/definitions/sku becomes #/$defs/sku, so it still finds the SKU rule.
- Two dependent keywords. dependencies held a list of names for coupon and a schema for gift_wrap. The list goes to dependentRequired and the schema to dependentSchemas.
- A tuple. location had "items": [number, number] with "additionalItems": false. In 2020-12 that is "prefixItems": [number, number] with "items": false.
- A keyword draft-07 never applied. sku said "$ref": "#/definitions/sku" with "maxLength": 3 beside it. Draft-07 ignored the maxLength, so a faithful migration drops it; 2020-12 would apply it.
- The proof. Every combination of 108 orders goes through the old schema under draft-07 rules and the new one under 2020-12 rules: all 108 give the same result. Keeping the maxLength would change 8 of them.
The orders are every combination of three SKUs, four locations, three coupon states and three gift states, so each rule of the schema is met and broken by some of them. Build the test set the same way for your own schemas, from the values each keyword accepts and refuses, rather than from production samples that may all pass.
Migrating by search and replace
Wrong: renaming definitions to $defs and items to prefixItems in a text editor. Right: a migration that knows
which keywords hold subschemas and which hold data, run through a test set under both drafts’ rules. A rename touches
an enum value spelt items and leaves the $ref siblings that change results.
Checkpoint
Check: Migrating and proving a migration
OpenAPI 3.1 and 3.2 schemas are JSON Schema 2020-12
OpenAPI 3.0 used its own Schema Object: The part of an OpenAPI document that describes a data type. In OpenAPI 3.1 and 3.2 it is a superset of JSON Schema 2020-12; in OpenAPI 3.0 it was an extended subset of an older JSON Schema draft., an extended subset of an older JSON Schema draft, in which type had to be a
single string and nullable: true let null through. OpenAPI 3.1 changed that: its Schema Object is a superset of
JSON Schema 2020-12, and the latest version, 3.2.1, keeps that. An OpenAPI document may name another dialect for all its
schemas in jsonSchemaDialect, and a schema’s own $schema overrides it. The program rewrites five 3.0 Schema Objects:
Python · drafts/openapi.py
"""OpenAPI 3.0 Schema Objects rewritten in the JSON Schema 2020-12 that OpenAPI 3.1 and 3.2 use, with notes."""
import json
NESTED = ("items", "not", "additionalProperties")
def upgrade(schema, notes, where=""):
"""The 2020-12 form of a 3.0 Schema Object; notes collects what a reader should know about the change."""
if not isinstance(schema, dict):
return schema
new = {}
for key, value in schema.items():
if key == "type" and schema.get("nullable") is True:
new["type"] = [value, "null"]
if "enum" in schema and None not in schema["enum"]:
notes.append((where, "the enum lists no null, so null is still refused, as in OpenAPI 3.0.4"))
elif key == "nullable":
if value is True and "type" not in schema:
notes.append((where, "nullable without type had no effect in OpenAPI 3.0.4, so it is dropped"))
elif key in ("minimum", "maximum") and schema.get("exclusive" + key.title()) is True:
continue # becomes the number of exclusiveMinimum or exclusiveMaximum below
elif key in ("exclusiveMinimum", "exclusiveMaximum"):
if value is True:
new[key] = schema["minimum" if key == "exclusiveMinimum" else "maximum"]
elif key == "example":
new["examples"] = [value]
elif key == "properties":
new[key] = {n: upgrade(s, notes, f"{where}/properties/{n}") for n, s in value.items()}
elif key in ("allOf", "anyOf", "oneOf"):
new[key] = [upgrade(s, notes, f"{where}/{key}/{i}") for i, s in enumerate(value)]
elif key in NESTED:
new[key] = upgrade(value, notes, f"{where}/{key}")
else:
new[key] = value
return new
BOOK = json.loads("""{"type": "object", "required": ["sku"], "properties": {
"sku": {"type": "string", "example": "BK-101"},
"subtitle": {"type": "string", "nullable": true},
"format": {"type": "string", "enum": ["paperback", "hardback"], "nullable": true},
"price_paise": {"type": "integer", "minimum": 0, "exclusiveMinimum": true},
"gift_note": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Note"}]}}}""")
compact = {"separators": (",", ":"), "ensure_ascii": False}
for name, old in BOOK["properties"].items():
notes = []
print(f"{name:<12}{json.dumps(old, **compact)}")
print(f"{' becomes':<12}{json.dumps(upgrade(old, notes), **compact)}")
for _, note in notes:
print(f" note: {note}") Output
sku {"type":"string","example":"BK-101"}
becomes {"type":"string","examples":["BK-101"]}
subtitle {"type":"string","nullable":true}
becomes {"type":["string","null"]}
format {"type":"string","enum":["paperback","hardback"],"nullable":true}
becomes {"type":["string","null"],"enum":["paperback","hardback"]}
note: the enum lists no null, so null is still refused, as in OpenAPI 3.0.4
price_paise {"type":"integer","minimum":0,"exclusiveMinimum":true}
becomes {"type":"integer","exclusiveMinimum":0}
gift_note {"nullable":true,"allOf":[{"$ref":"#/components/schemas/Note"}]}
becomes {"allOf":[{"$ref":"#/components/schemas/Note"}]}
note: nullable without type had no effect in OpenAPI 3.0.4, so it is dropped
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 openapi.py
JavaScript · drafts/openapi.mjs
// OpenAPI 3.0 Schema Objects rewritten in the JSON Schema 2020-12 that OpenAPI 3.1 and 3.2 use, with notes.
const NESTED = ['items', 'not', 'additionalProperties'];
const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// The 2020-12 form of a 3.0 Schema Object; notes collects what a reader should know about the change.
function upgrade(schema, notes, where = '') {
if (!isObject(schema)) return schema;
const next = {};
for (const [key, value] of Object.entries(schema)) {
if (key === 'type' && schema.nullable === true) {
next.type = [value, 'null'];
if (Object.hasOwn(schema, 'enum') && !schema.enum.includes(null)) notes.push([where, 'the enum lists no null, so null is still refused, as in OpenAPI 3.0.4']);
} else if (key === 'nullable') {
if (value === true && !Object.hasOwn(schema, 'type')) notes.push([where, 'nullable without type had no effect in OpenAPI 3.0.4, so it is dropped']);
} else if ((key === 'minimum' || key === 'maximum') && schema[`exclusive${key[0].toUpperCase()}${key.slice(1)}`] === true) {
continue; // becomes the number of exclusiveMinimum or exclusiveMaximum below
} else if (key === 'exclusiveMinimum' || key === 'exclusiveMaximum') {
if (value === true) next[key] = schema[key === 'exclusiveMinimum' ? 'minimum' : 'maximum'];
} else if (key === 'example') {
next.examples = [value];
} else if (key === 'properties') {
next[key] = Object.fromEntries(Object.entries(value).map(([n, s]) => [n, upgrade(s, notes, `${where}/properties/${n}`)]));
} else if (['allOf', 'anyOf', 'oneOf'].includes(key)) {
next[key] = value.map((s, i) => upgrade(s, notes, `${where}/${key}/${i}`));
} else if (NESTED.includes(key)) {
next[key] = upgrade(value, notes, `${where}/${key}`);
} else {
next[key] = value;
}
}
return next;
}
const BOOK = JSON.parse(`{"type": "object", "required": ["sku"], "properties": {
"sku": {"type": "string", "example": "BK-101"},
"subtitle": {"type": "string", "nullable": true},
"format": {"type": "string", "enum": ["paperback", "hardback"], "nullable": true},
"price_paise": {"type": "integer", "minimum": 0, "exclusiveMinimum": true},
"gift_note": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Note"}]}}}`);
for (const [name, old] of Object.entries(BOOK.properties)) {
const notes = [];
console.log(`${name.padEnd(12)}${JSON.stringify(old)}`);
console.log(`${' becomes'.padEnd(12)}${JSON.stringify(upgrade(old, notes))}`);
for (const [, note] of notes) console.log(` note: ${note}`);
} Output
sku {"type":"string","example":"BK-101"}
becomes {"type":"string","examples":["BK-101"]}
subtitle {"type":"string","nullable":true}
becomes {"type":["string","null"]}
format {"type":"string","enum":["paperback","hardback"],"nullable":true}
becomes {"type":["string","null"],"enum":["paperback","hardback"]}
note: the enum lists no null, so null is still refused, as in OpenAPI 3.0.4
price_paise {"type":"integer","minimum":0,"exclusiveMinimum":true}
becomes {"type":"integer","exclusiveMinimum":0}
gift_note {"nullable":true,"allOf":[{"$ref":"#/components/schemas/Note"}]}
becomes {"allOf":[{"$ref":"#/components/schemas/Note"}]}
note: nullable without type had no effect in OpenAPI 3.0.4, so it is dropped
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node openapi.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
| OpenAPI 3.0 | OpenAPI 3.1 and 3.2 |
|---|---|
"type": "string", "nullable": true |
"type": ["string", "null"] |
"minimum": 0, "exclusiveMinimum": true |
"exclusiveMinimum": 0 |
"example": "BK-101" |
"examples": ["BK-101"] |
nullable without type |
nothing: it had no effect |
The two notes are where people go wrong. OpenAPI 3.0.4 says nullable adds null only to the type beside it and that
other keywords still apply, so a nullable format with an enum of two names refused null in 3.0 and still does after
the rewrite: add null to the enum if it should pass. Since 3.1, example is deprecated in favour of JSON Schema’s
examples, and discriminator stays an OpenAPI keyword that must not change the validation outcome.
Generated types are a projection of the schema
Code generators read a schema and write types for a language, so that a program can use the data with names and completion. A type, though, says less than a schema. The program writes TypeScript interfaces from the order schema and lists every rule they cannot hold:
Python · drafts/typegen.py
"""TypeScript declarations generated from an order schema, the rules they cannot hold, and a document that slips by."""
import json
import re
ORDER = json.loads("""
{
"title": "Order",
"type": "object",
"properties": {
"id": {"type": "string", "pattern": "^BK-[0-9]{5}$"},
"status": {"enum": ["paid", "shipped", "refunded"]},
"qty": {"type": "integer", "minimum": 1},
"note": {"type": ["string", "null"], "maxLength": 120},
"gift": {"type": "boolean"},
"lines": {
"type": "array",
"minItems": 1,
"items": {
"title": "OrderLine",
"type": "object",
"properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
"required": ["sku", "qty"]
}
}
},
"required": ["id", "status", "qty", "note", "lines"]
}
""")
TS = {"string": "string", "integer": "number", "number": "number", "boolean": "boolean", "null": "null"}
LOST = ("pattern", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems")
def ts_type(schema, out, lost, where):
"""The TypeScript type for schema, with object schemas written as interfaces into out."""
lost += [(where, f"{k} {schema[k]}") for k in LOST if k in schema]
if schema.get("type") == "integer":
lost.append((where, "integer: TypeScript has only number"))
if "enum" in schema:
return " | ".join(json.dumps(v) for v in schema["enum"])
kinds = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
if kinds == ["object"]:
interface(schema, out, lost, where)
return schema["title"]
if kinds == ["array"]:
return ts_type(schema["items"], out, lost, where + "/items") + "[]"
return " | ".join(TS[k] for k in kinds)
def interface(schema, out, lost, where):
lines, members = [f"export interface {schema['title']} {{"], []
for name, sub in schema["properties"].items():
optional = "" if name in schema.get("required", []) else "?"
members.append(f" {name}{optional}: {ts_type(sub, out, lost, f'{where}/properties/{name}')};")
out.insert(0, "\n".join(lines + members + ["}"]))
out, lost = [], []
interface(ORDER, out, lost, "")
print("\n\n".join(out))
print()
print("rules the types cannot hold, so the schema must still be checked at run time:")
for where, rule in lost:
print(f" {where}: {rule}")
doc = {"id": "x", "status": "paid", "qty": 0, "note": None, "lines": []}
broken = [w for w, r in lost if r.startswith("pattern") and not re.search(r.split(" ", 1)[1], doc["id"])]
broken += [w for w, r in lost if w == "/properties/qty" and r == "minimum 1" and doc["qty"] < 1]
broken += [w for w, r in lost if r == "minItems 1" and len(doc["lines"]) < 1]
print()
print("the document", json.dumps(doc, separators=(",", ":")), "has every member the types ask for,")
print("and breaks the schema at", ", ".join(broken)) Output
export interface Order {
id: string;
status: "paid" | "shipped" | "refunded";
qty: number;
note: string | null;
gift?: boolean;
lines: OrderLine[];
}
export interface OrderLine {
sku: string;
qty: number;
}
rules the types cannot hold, so the schema must still be checked at run time:
/properties/id: pattern ^BK-[0-9]{5}$
/properties/qty: minimum 1
/properties/qty: integer: TypeScript has only number
/properties/note: maxLength 120
/properties/lines: minItems 1
/properties/lines/items/properties/qty: minimum 1
/properties/lines/items/properties/qty: integer: TypeScript has only number
the document {"id":"x","status":"paid","qty":0,"note":null,"lines":[]} has every member the types ask for,
and breaks the schema at /properties/id, /properties/qty, /properties/lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typegen.py
JavaScript · drafts/typegen.mjs
// TypeScript declarations generated from an order schema, the rules they cannot hold, and a document that slips by.
const ORDER = JSON.parse(`
{
"title": "Order",
"type": "object",
"properties": {
"id": {"type": "string", "pattern": "^BK-[0-9]{5}$"},
"status": {"enum": ["paid", "shipped", "refunded"]},
"qty": {"type": "integer", "minimum": 1},
"note": {"type": ["string", "null"], "maxLength": 120},
"gift": {"type": "boolean"},
"lines": {
"type": "array",
"minItems": 1,
"items": {
"title": "OrderLine",
"type": "object",
"properties": {"sku": {"type": "string"}, "qty": {"type": "integer", "minimum": 1}},
"required": ["sku", "qty"]
}
}
},
"required": ["id", "status", "qty", "note", "lines"]
}
`);
const TS = { string: 'string', integer: 'number', number: 'number', boolean: 'boolean', null: 'null' };
const LOST = ['pattern', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems'];
// The TypeScript type for schema, with object schemas written as interfaces into out.
function tsType(schema, out, lost, where) {
for (const k of LOST) if (Object.hasOwn(schema, k)) lost.push([where, `${k} ${schema[k]}`]);
if (schema.type === 'integer') lost.push([where, 'integer: TypeScript has only number']);
if (Object.hasOwn(schema, 'enum')) return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
const kinds = Array.isArray(schema.type) ? schema.type : [schema.type];
if (kinds.length === 1 && kinds[0] === 'object') {
writeInterface(schema, out, lost, where);
return schema.title;
}
if (kinds.length === 1 && kinds[0] === 'array') return `${tsType(schema.items, out, lost, `${where}/items`)}[]`;
return kinds.map((k) => TS[k]).join(' | ');
}
function writeInterface(schema, out, lost, where) {
const members = [];
for (const [name, sub] of Object.entries(schema.properties)) {
const optional = (schema.required ?? []).includes(name) ? '' : '?';
members.push(` ${name}${optional}: ${tsType(sub, out, lost, `${where}/properties/${name}`)};`);
}
out.unshift([`export interface ${schema.title} {`, ...members, '}'].join('\n'));
}
const out = [];
const lost = [];
writeInterface(ORDER, out, lost, '');
console.log(out.join('\n\n'));
console.log();
console.log('rules the types cannot hold, so the schema must still be checked at run time:');
for (const [where, rule] of lost) console.log(` ${where}: ${rule}`);
const doc = { id: 'x', status: 'paid', qty: 0, note: null, lines: [] };
const broken = [
...lost.filter(([, r]) => r.startsWith('pattern') && !new RegExp(r.split(' ').slice(1).join(' '), 'u').test(doc.id)).map(([w]) => w),
...lost.filter(([w, r]) => w === '/properties/qty' && r === 'minimum 1' && doc.qty < 1).map(([w]) => w),
...lost.filter(([, r]) => r === 'minItems 1' && doc.lines.length < 1).map(([w]) => w),
];
console.log();
console.log('the document', JSON.stringify(doc), 'has every member the types ask for,');
console.log('and breaks the schema at', broken.join(', ')); Output
export interface Order {
id: string;
status: "paid" | "shipped" | "refunded";
qty: number;
note: string | null;
gift?: boolean;
lines: OrderLine[];
}
export interface OrderLine {
sku: string;
qty: number;
}
rules the types cannot hold, so the schema must still be checked at run time:
/properties/id: pattern ^BK-[0-9]{5}$
/properties/qty: minimum 1
/properties/qty: integer: TypeScript has only number
/properties/note: maxLength 120
/properties/lines: minItems 1
/properties/lines/items/properties/qty: minimum 1
/properties/lines/items/properties/qty: integer: TypeScript has only number
the document {"id":"x","status":"paid","qty":0,"note":null,"lines":[]} has every member the types ask for,
and breaks the schema at /properties/id, /properties/qty, /properties/lines
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node typegen.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 enum became a union of string literals and the type list ["string", "null"] became string | null, both exact.
A member outside required got a ?. But the generated types hold none of the pattern, the minimum, the maximum
length or the count of items, and TypeScript has a single number type with no integer, so seven rules exist only in
the schema. The last lines show the
cost: a document with an id of "x", 0 copies and no lines has every member the types ask for, and breaks the schema in
three places.
Add a rule the types cannot hold
Press Edit, add "maxItems": 50 to lines in the schema, and run it. The interfaces stay exactly as they were, and
one more rule joins the list of what only the schema checks.
So keep the schema as the source of truth and generate from it, never the other way round, and keep validating at run time with the same schema, at every boundary where data enters. Check the generated code into a build step, regenerate it whenever the schema changes, and fail the build when the two differ. JSON Type Definition: A small schema language for JSON, published as the Experimental RFC 8927, whose main goals are code generation from schemas and portable validation with standard error indicators., an Experimental RFC, takes the other route: a deliberately small schema language whose main goals are code generation and portable validation, and which leaves out much of what JSON Schema can say.
JSON to TypeScript Generate TypeScript interfaces or a Zod schema from JSON samples or from a JSON Schema, in your browser.Key idea
A generated type is a projection of the schema: it keeps names, shapes and enums and drops patterns, ranges and lengths, so the schema stays the source of truth and still validates at run time.
Checkpoint
Check: OpenAPI 3.0 schemas in 3.1 and 3.2 · Generated types and the schema
Practise: migrate draft-07 schemas
The exercise asks for the migration function of the second section: definitions, dependencies, array items,
additionalItems and the keywords beside $ref, at every level of subschemas, without touching data such as enum
values. The tests compare your output with the expected 2020-12 schema for each case:
Exercise · Medium · Python, JavaScript
Migrate draft-07 schemas to 2020-12
Write migrate(schema) in migrate07.py (Python) and migrate07.mjs (JavaScript). It takes a draft-07 schema and returns a new 2020-12 schema that accepts and refuses exactly the same documents. Apply these rules at every level of subschemas, as the lesson's migration program does:
$schemabecomes"https://json-schema.org/draft/2020-12/schema".definitionsbecomes$defs, and a$refthat starts with#/definitions/starts with#/$defs/instead.dependenciesis split: an entry whose value is a list of names goes todependentRequired, an entry whose value is a schema todependentSchemas.- An array under
itemsbecomesprefixItems, andadditionalItemsbeside it becomesitems.additionalItemsbeside anitemsthat is a single schema is dropped, because draft-07 ignored it there. - A schema with
$refkeeps only the$refof its rules, because draft-07 ignores every other keyword beside it.$schemaanddefinitionsbeside it stay, changed as above: they hold no rule, and the$refmay point intodefinitions. - Values that are data, such as those of
enum,const,defaultandexamples, are copied unchanged.
Subschemas are the values under properties, patternProperties, definitions, allOf, anyOf, oneOf, items, not, if, then, else, additionalProperties, contains and propertyNames, and the schema values of dependencies. The argument must not change.
Python · Starter code · migrate07.py
def migrate(schema):
"""The 2020-12 schema that accepts and refuses the same documents as the draft-07 schema (rules in the prompt)."""
return schema The sample tests · test_migrate07.py
import copy
from migrate07 import migrate
D7 = "http://json-schema.org/draft-07/schema#"
D2020 = "https://json-schema.org/draft/2020-12/schema"
def test_schema_and_definitions():
"""renames $schema's value, definitions and the references into it"""
old = {"$schema": D7, "definitions": {"sku": {"type": "string"}}, "properties": {"sku": {"$ref": "#/definitions/sku"}}}
assert migrate(old) == {"$schema": D2020, "$defs": {"sku": {"type": "string"}}, "properties": {"sku": {"$ref": "#/$defs/sku"}}}
def test_dependencies():
"""splits dependencies into dependentRequired and dependentSchemas"""
old = {"dependencies": {"coupon": ["discount_paise"], "gift_wrap": {"required": ["gift_note"]}}}
assert migrate(old) == {"dependentRequired": {"coupon": ["discount_paise"]},
"dependentSchemas": {"gift_wrap": {"required": ["gift_note"]}}}
def test_tuples():
"""turns array items into prefixItems and additionalItems into items, and drops additionalItems beside a schema"""
assert migrate({"items": [{"type": "number"}, {"type": "number"}], "additionalItems": False}) == {
"prefixItems": [{"type": "number"}, {"type": "number"}], "items": False}
assert migrate({"items": [{"type": "string"}]}) == {"prefixItems": [{"type": "string"}]}
assert migrate({"items": {"type": "string"}, "additionalItems": False}) == {"items": {"type": "string"}}
def test_ref_siblings():
"""keeps the $ref and drops the rules beside it, as draft-07 ignored them, but keeps $schema and definitions"""
assert migrate({"$ref": "#/definitions/sku", "maxLength": 3, "description": "a SKU"}) == {"$ref": "#/$defs/sku"}
assert migrate({"$ref": "address.json", "title": "Address"}) == {"$ref": "address.json"}
old = {"$schema": D7, "$ref": "#/definitions/order", "title": "Order",
"definitions": {"order": {"required": ["sku"], "properties": {"sku": {"$ref": "#/definitions/sku"}}},
"sku": {"type": "string"}}}
assert migrate(old) == {"$schema": D2020, "$ref": "#/$defs/order",
"$defs": {"order": {"required": ["sku"], "properties": {"sku": {"$ref": "#/$defs/sku"}}},
"sku": {"type": "string"}}}
def test_nested():
"""migrates every level of subschemas"""
old = {"properties": {"lines": {"type": "array", "items": {"properties": {
"tags": {"items": [{"type": "string"}], "additionalItems": {"type": "integer"}}}}}},
"allOf": [{"dependencies": {"a": ["b"]}}], "not": {"definitions": {"x": {}}}}
assert migrate(old) == {"properties": {"lines": {"type": "array", "items": {"properties": {
"tags": {"prefixItems": [{"type": "string"}], "items": {"type": "integer"}}}}}},
"allOf": [{"dependentRequired": {"a": ["b"]}}], "not": {"$defs": {"x": {}}}}
def test_data_is_copied():
"""copies enum, const, default and examples unchanged, and changes nothing in the argument"""
old = {"enum": [{"definitions": 1}], "const": {"items": [1]}, "default": {"$ref": "#/definitions/x"},
"examples": [{"dependencies": {}}]}
before = copy.deepcopy(old)
assert migrate(old) == before
assert old == before JavaScript · Starter code · migrate07.mjs
// The 2020-12 schema that accepts and refuses the same documents as the draft-07 schema (rules in the prompt).
export function migrate(schema) {
return schema;
} The sample tests · migrate07.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { migrate } from './migrate07.mjs';
const D7 = 'http://json-schema.org/draft-07/schema#';
const D2020 = 'https://json-schema.org/draft/2020-12/schema';
const copy = (x) => JSON.parse(JSON.stringify(x));
test('renames $schema\'s value, definitions and the references into it', () => {
const old = { $schema: D7, definitions: { sku: { type: 'string' } }, properties: { sku: { $ref: '#/definitions/sku' } } };
assert.deepEqual(migrate(old), { $schema: D2020, $defs: { sku: { type: 'string' } }, properties: { sku: { $ref: '#/$defs/sku' } } });
});
test('splits dependencies into dependentRequired and dependentSchemas', () => {
const old = { dependencies: { coupon: ['discount_paise'], gift_wrap: { required: ['gift_note'] } } };
assert.deepEqual(migrate(old), { dependentRequired: { coupon: ['discount_paise'] }, dependentSchemas: { gift_wrap: { required: ['gift_note'] } } });
});
test('turns array items into prefixItems and additionalItems into items, and drops additionalItems beside a schema', () => {
assert.deepEqual(migrate({ items: [{ type: 'number' }, { type: 'number' }], additionalItems: false }), { prefixItems: [{ type: 'number' }, { type: 'number' }], items: false });
assert.deepEqual(migrate({ items: [{ type: 'string' }] }), { prefixItems: [{ type: 'string' }] });
assert.deepEqual(migrate({ items: { type: 'string' }, additionalItems: false }), { items: { type: 'string' } });
});
test('keeps the $ref and drops the rules beside it, as draft-07 ignored them, but keeps $schema and definitions', () => {
assert.deepEqual(migrate({ $ref: '#/definitions/sku', maxLength: 3, description: 'a SKU' }), { $ref: '#/$defs/sku' });
assert.deepEqual(migrate({ $ref: 'address.json', title: 'Address' }), { $ref: 'address.json' });
const old = {
$schema: D7,
$ref: '#/definitions/order',
title: 'Order',
definitions: { order: { required: ['sku'], properties: { sku: { $ref: '#/definitions/sku' } } }, sku: { type: 'string' } },
};
assert.deepEqual(migrate(old), {
$schema: D2020,
$ref: '#/$defs/order',
$defs: { order: { required: ['sku'], properties: { sku: { $ref: '#/$defs/sku' } } }, sku: { type: 'string' } },
});
});
test('migrates every level of subschemas', () => {
const old = {
properties: { lines: { type: 'array', items: { properties: { tags: { items: [{ type: 'string' }], additionalItems: { type: 'integer' } } } } } },
allOf: [{ dependencies: { a: ['b'] } }],
not: { definitions: { x: {} } },
};
assert.deepEqual(migrate(old), {
properties: { lines: { type: 'array', items: { properties: { tags: { prefixItems: [{ type: 'string' }], items: { type: 'integer' } } } } } },
allOf: [{ dependentRequired: { a: ['b'] } }],
not: { $defs: { x: {} } },
});
});
test('copies enum, const, default and examples unchanged, and changes nothing in the argument', () => {
const old = { enum: [{ definitions: 1 }], const: { items: [1] }, default: { $ref: '#/definitions/x' }, examples: [{ dependencies: {} }] };
const before = copy(old);
assert.deepEqual(migrate(old), before);
assert.deepEqual(old, before);
}); A hint
Write one recursive function that builds a new object. Handle $ref first: migrate only its $schema and definitions, add the $ref, and return at once. Then go through the schema's members once: rename or split the keywords of the list, call migrate on every subschema, and copy everything else with a deep copy. Read additionalItems from the old schema while you handle an array under items, and skip it when you meet it on its own.
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.
The self-check covers drafts, migrations, OpenAPI and generated types, and it begins with whichever of them your earlier answers show is weakest:
Practice that adapts to you
Practise this lesson
12 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 (2–5 years): how do you write OpenAPI 3.0’s "type": "string", "nullable": true in OpenAPI 3.1?
As "type": ["string", "null"]. OpenAPI 3.1 and 3.2 use JSON Schema 2020-12, which has no nullable keyword and lets
type be a list of type names. Two details matter when you convert: nullable without a type beside it had no effect
in OpenAPI 3.0.4, so it simply goes; and other keywords keep applying, so an enum without null still refuses null
after the conversion. Add null to the enum if null should pass.
Two harder questions follow, about a migration that started refusing valid requests and about generated types that drifted from the API. 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
Your team migrated 40 draft-07 schemas to 2020-12 with a script that renames keywords, and some valid requests are now refused. What did the script most likely miss, and how would you prove a migration safe before it ships?
-
Mid to senior
Front-end types are generated from an API's JSON Schema, and the server validates with the same schema. A release adds a pattern to the order id and a minimum to the quantity, and the generated types do not change at all. Is that a bug, and how do you keep schema, types and validation in step?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which draft changes alter results without raising any error?
1.0 counting as an integer, keywords next to $ref now applying, and format becoming an annotation. The same schema can give other answers under another draft.
-
What took the place of definitions, dependencies and the array form of items?
$defs; dependentRequired and dependentSchemas; and prefixItems, with items for the rest. $recursiveRef became $dynamicRef.
-
How do you show that a draft-07 to 2020-12 migration changed nothing?
Run one test set that covers every combination through the old schema under draft-07 rules and the new one under 2020-12, and compare every result.
-
How is OpenAPI 3.0's "nullable": true written in OpenAPI 3.1?
As a type list, "type": ["string", "null"]. An enum without null still refuses null, so add null to the enum as well when it must pass.
-
What else changes when OpenAPI 3.0 schemas move to 3.1?
A boolean exclusiveMinimum beside minimum becomes the number itself, example becomes an examples list, and nullable without a type simply goes, since it had no effect.
-
What do types generated from a schema lose?
Patterns, ranges and lengths; they keep names, shapes and enums. So the schema stays the source of truth and keeps validating at run time.
Key takeaways
- Each draft changed meanings as well as names; 1.0 as an integer, keywords beside
$refandformatas an annotation change results without any error, and 2020-12 is still the current version. - A migration is proved by running a test set of every combination through the old schema under its draft’s rules and the new one under 2020-12’s, and a faithful migration drops the keywords draft-07 ignored.
- OpenAPI 3.1 and 3.2 use JSON Schema 2020-12, so
nullablebecomes a type list, boolean exclusive limits become numbers andexamplebecomesexamples, while anenumwithoutnullstill refusesnull. - Generated types keep shapes and enums but lose patterns, ranges and lengths, so the schema stays the source of truth and keeps validating at run time.
Terms in this lesson
- JSON Type Definition
- A small schema language for JSON, published as the Experimental RFC 8927, whose main goals are code generation from schemas and portable validation with standard error indicators.
- Schema Object
- The part of an OpenAPI document that describes a data type. In OpenAPI 3.1 and 3.2 it is a superset of JSON Schema 2020-12; in OpenAPI 3.0 it was an extended subset of an older JSON Schema draft.
References
- JSON Schema specification (current version 2020-12) (JSON Schema)
- JSON Schema draft-06 release notes (JSON Schema)
- JSON Schema draft-07 release notes (JSON Schema)
- JSON Schema 2019-09 release notes (JSON Schema)
- JSON Schema 2020-12 release notes (JSON Schema)
- JSON Schema core, draft-07: schema references with $ref (JSON Schema)
- JSON Schema and IETF (JSON Schema)
- Moving Toward a Stable Spec (JSON Schema)
- OpenAPI Specification 3.2.1: Schema Object (OpenAPI Initiative)
- OpenAPI Specification 3.1.1: Schema Object and jsonSchemaDialect (OpenAPI Initiative)
- OpenAPI Specification 3.0.4: Schema Object (nullable) (OpenAPI Initiative)
- OpenAPI Specification versions (OpenAPI Initiative)
- TypeScript handbook: everyday types (Microsoft)
- RFC 8927: JSON Type Definition (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress