Your country

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

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

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

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.

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

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:

Timeline · 6 events in order Which draft changed what a schema means Walk through the drafts in order. Each event lists the changes that make an old schema mean something else.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. Question 1 of 2 Which draft changes alter results without any error to warn you? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Keywords beside $ref began to apply in 2019-09
    • format stopped checking by default in 2019-09
    • 1.0 became an integer in draft-06

    Renames are visible in the schema; meaning changes are visible only in results, which is why a migration is proved by running documents, not by reading the schema.

  2. Question 2 of 2 Which $schema URI names the current version of JSON Schema?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: https://json-schema.org/draft/2020-12/schema

    Name the version in every schema, so that each validator applies the meanings you wrote for.

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.

A draft-07 schema migrated, then 108 orders checked under both drafts

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

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:

Interactive · stage by stage Migrating the order schema, one rule at a time Step through the five rules the old schema needs, then the check that proves nothing changed.
Migrating the order schema, one rule at a time, step 6 of 6. Showing $schema (now …/draft/2020-12/schema), definitions (#/$defs/sku), dependencies (coupon, gift_wrap moved), items as an array (prefixItems, items false), Keywords beside $ref (maxLength 3 dropped), Both drafts' rules (108 of 108 the same). In focus: Both drafts' rules.$schemanow …/draft/2020-12/schemadefinitions#/$defs/skudependenciescoupon, gift_wrap moveditems as an arrayprefixItems, items falseKeywords beside $refmaxLength 3 droppedBoth drafts' rules108 of 108 the same

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

Every step as text
  1. 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.
  2. $defs. definitions becomes $defs, and the reference #/definitions/sku becomes #/$defs/sku, so it still finds the SKU rule.
  3. 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.
  4. A tuple. location had "items": [number, number] with "additionalItems": false. In 2020-12 that is "prefixItems": [number, number] with "items": false.
  5. 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.
  6. 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

  1. Question 1 of 2 What proved the order schema's migration correct?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 108 orders got the same result from the old schema under draft-07 rules and the new one under 2020-12 rules

    Run both, compare every result, and make the test set break each rule of the schema at least once.

  2. Question 2 of 2 A draft-07 schema has {"$ref": "#/definitions/sku", "maxLength": 3}. What does a faithful migration to 2020-12 write?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: {"$ref": "#/$defs/sku"}, because draft-07 never applied the maxLength

    If the authors really wanted the maxLength, adding it is a separate change, with its own tests.

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:

Five OpenAPI 3.0 schemas rewritten for 3.1 and 3.2

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

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.

OpenAPI / Swagger Editor & Validator Validate an OpenAPI 3.0, 3.1 or 3.2 document, then upgrade a 3.0 or Swagger 2.0 description to 3.1 and check the result.

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:

TypeScript from an order schema, and what the types lose

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

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

  1. Question 1 of 2 An OpenAPI 3.0 schema is {"type": "string", "enum": ["paperback", "hardback"], "nullable": true}. After the rewrite to {"type": ["string", "null"], "enum": ["paperback", "hardback"]}, is null valid?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because the enum does not list null, as in OpenAPI 3.0.4

    The program's note said so: the enum lists no null, so null is still refused.

  2. Question 2 of 2 Why did the document with id "x", 0 copies and no lines fit the generated TypeScript types?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The types hold no pattern, minimum or count of items, so only the schema can refuse it

    Keep validating with the schema at run time, at every boundary where data enters.

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:

  • $schema becomes "https://json-schema.org/draft/2020-12/schema".
  • definitions becomes $defs, and a $ref that starts with #/definitions/ starts with #/$defs/ instead.
  • dependencies is split: an entry whose value is a list of names goes to dependentRequired, an entry whose value is a schema to dependentSchemas.
  • An array under items becomes prefixItems, and additionalItems beside it becomes items. additionalItems beside an items that is a single schema is dropped, because draft-07 ignored it there.
  • A schema with $ref keeps only the $ref of its rules, because draft-07 ignores every other keyword beside it. $schema and definitions beside it stay, changed as above: they hold no rule, and the $ref may point into definitions.
  • Values that are data, such as those of enum, const, default and examples, 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.

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.

  1. Question 1 of 12 Which $schema value names draft-07?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: http://json-schema.org/draft-07/schema#

    The URI is how a validator picks the rules. A schema without one is read by whatever default the validator has.

  2. Question 2 of 12 How is the draft-04 pair "minimum": 0, "exclusiveMinimum": true written from draft-06 on?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: "exclusiveMinimum": 0

    OpenAPI 3.0 inherited the draft-04 form, which is why the OpenAPI rewrite turns the pair into one number too.

  3. Question 3 of 12 A team's draft-04 validator refuses "qty": 2.0 for "type": "integer", and their 2020-12 validator accepts it. Which one is following its draft?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Both

    This is one of the silent changes: the same document, a different result, no error anywhere.

  4. Question 4 of 12 When is a migration of a schema correct?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: When every document gets the same result before and after it

    The proof is a run: old schema under its draft's rules against new schema under the new rules.

  5. Question 5 of 12 A draft-07 schema has "items": {"type": "string"} and "additionalItems": false. What does the migration do with additionalItems?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Drops it, because draft-07 ignored additionalItems when items was a single schema

    additionalItems only ever applied beside an array of positions, so it moves to items only in that case.

  6. Question 6 of 12 Why did the migration test use every combination of chosen values rather than a sample of real orders?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Real orders may all pass, while combinations meet and break every rule at least once

    Choose for each keyword values it accepts and values it refuses, then combine them.

  7. Question 7 of 12 How is "type": "string", "nullable": true written in OpenAPI 3.1?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: "type": ["string", "null"]

    The OpenAPI editor's upgrade to 3.1 makes this change, and Swagger 2.0's x-nullable becomes the same type list.

  8. Question 8 of 12 An OpenAPI 3.0 schema says "minimum": 0, "exclusiveMinimum": true. What does it become in 3.1?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: "exclusiveMinimum": 0

    The program printed {"type":"integer","exclusiveMinimum":0} for price_paise.

  9. Question 9 of 12 An OpenAPI 3.1 document sets jsonSchemaDialect, and one of its schemas has a $schema of its own. Which dialect does that schema follow?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Its own $schema

    jsonSchemaDialect sets the document's default; $schema in a resource root wins over it.

  10. Question 10 of 12 What TypeScript type did the generator write for "type": "integer"?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: number, because TypeScript has no integer type

    The program listed "integer: TypeScript has only number" among the rules the types cannot hold.

  11. Question 11 of 12 Which should a team edit by hand: the schema or the generated types?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The schema, and regenerate the types from it

    Make the build fail when the checked-in types differ from what the schema generates.

  12. Question 12 of 12 The front end is written against generated types, so a teammate wants to stop validating requests on the server. What do you say?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Keep validating with the schema, because types are not checked at run time and drop rules anyway

    Types help the people writing code; the schema protects the system from the data it receives.

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.

  1. 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?

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

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 $ref and format as 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 nullable becomes a type list, boolean exclusive limits become numbers and example becomes examples, while an enum without null still refuses null.
  • 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

Related tools

Report a problem with this lesson

Quick answers and tool search

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