How-to › Describe your API

How to describe payloads in JSON Schema alone and generate types#

Publish standalone JSON Schema 2020-12 files, bundle them offline, generate TypeScript and Python types, and wrap them in an OpenAPI 3.1 shell when a tool insists.

Audience
Library maintainer
Level
intermediate
Topic
Describe an API without OpenAPI
Languages
TypeScript and Python
Verified

Your service exchanges JSON payloads over a queue and a webhook, and the only description of them lives in a TypeScript interface and a Python dataclass that have drifted apart. There are no endpoints to describe, so an OpenAPI document would be a shell around schemas. Every generator you try wants either that shell or an earlier draft of JSON Schema than the one you write.

What you get

You will end up with standalone schema files, an offline bundler, generated TypeScript and Python types, a probe showing what each generator dropped, and an OpenAPI 3.1 shell for tools that insist. This is for you if you maintain payload contracts without an HTTP surface.

Short answer

Write one JSON Schema 2020-12 file per payload, each with a $schema, an $id under one base URL, $ref for shared types, and $defs for local ones. Bundle them with json-schema-ref-parser through a resolver that maps the base URL onto the directory, then run json-schema-to-typescript, quicktype, and datamodel-code-generator on the result. Keep one schema per 2020-12 keyword and check what each generator makes of it. A tool that wants OpenAPI gets a 3.1 shell whose components point at the files.

You will need

Node 22 or later, Python 3 with pip, and a JSON Schema 2020-12 document or two. Verified 2026-10-21 against Node 22.22.0 and Python 3.13.16, with @apidevtools/json-schema-ref-parser 16.0.5, json-schema-to-typescript 16.0.0, quicktype 26.0.0, Ajv 8.20.0, and datamodel-code-generator 0.85.0. The Python generator is installed by npm’s postinstall script into vendor/ from a fully pinned requirements.txt, so npm ci sets up both languages. The sample describes an order with a customer, line items, a coordinate pair, and a status, across two files.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI 3.1 components.schemasThe payloads travel over HTTP endpoints you also need to describe, and your tooling reads OpenAPIA document whose paths may be empty, a second base for $ref resolution, and the OAS dialect’s own keywords layered on the schemasNothing has an endpoint, or consumers validate payloads in a queue or a file
Standalone JSON Schema 2020-12 filesPayloads with no endpoint, shared across languages, validated at the edges by Ajv or PydanticNo operations, so anything endpoint-shaped needs a shell, and generators built on an earlier draft drop the keywords you rely onYour tooling reads OpenAPI only, and payloads exist only inside requests
The @typespec/json-schema emitterYou already write TypeSpec, or want one source that emits OpenAPI and JSON SchemaA compile step on every contributor’s machine, a Node toolchain, and schemas you edit upstream of the files other tools readThe schemas already exist as files, and a second source of truth is the thing you are avoiding

Standalone files are the portable choice, and the one with no operations in it. A queue consumer and a webhook receiver read the same $id, while an SDK generator gets nothing to call. An OpenAPI 3.1 document carries the same schemas under components.schemas, because its Schema Object is a superset of the 2020-12 dialect. The price is a shell with an empty or invented paths. TypeSpec emits either from one source, and adds a compiler every contributor runs.

Give every payload its own file and $id

Each payload is one file. $schema names the dialect, $id gives the file a URL under one base, $ref points at a sibling by filename, and $defs holds the types no other file needs. The order schema uses two keywords that arrived after draft-07, prefixItems for a fixed-shape array and unevaluatedProperties to close the object, and a third, $dynamicRef, sits in a test schema of its own.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/orders/order.schema.json",
  "title": "Order",
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "customer": { "$ref": "customer.schema.json" },
    "lines": { "type": "array", "items": { "$ref": "#/$defs/Line" }, "minItems": 1 },
    "coordinates": {
      "type": "array",
      "prefixItems": [{ "type": "number" }, { "type": "number" }],
      "items": false,
      "minItems": 2
    },
    "status": { "enum": ["open", "paid", "shipped"] }
  },
  "required": ["id", "customer", "lines", "status"],
  "unevaluatedProperties": false,

The relative $ref resolves against the schema’s $id, as the core specification requires, so it names https://schemas.example.com/orders/customer.schema.json rather than the file beside it.1 Nothing is served at that address. Closing that gap is the bundling step’s whole job.

Bundle through a resolver that knows the base URL

json-schema-ref-parser resolves references through pluggable resolvers, and the one in resolve.mjs claims every URL under the base and reads the matching file. The network resolver is switched off, so a typo in an $id fails here rather than in a fetch.

export const BASE = 'https://schemas.example.com/orders/'

export const local = {
  order: 1,
  canRead: (file) => file.url.startsWith(BASE),
  read: (file) => {
    const path = file.url.slice(BASE.length)
    const dir = path.startsWith('keywords/') ? './' : './schemas/'
    return readFile(new URL(dir + path, import.meta.url), 'utf8')
  },
}

// The options every $RefParser call on this page uses: the local resolver on, the network off.
export const offline = { resolve: { local, http: false } }
node bundle.mjs
bundled https://schemas.example.com/orders/order.schema.json
  $defs: Line
  $ref values left: #/$defs/Line
  customer: inlined as Customer, keeping its own $id

The bundle inlines the customer where it is used, with its $id and $schema still on it, and leaves the local Line reference alone. A tool that reads one document gets one document, and the $id on the inlined schema keeps that schema’s own relative references correct.

Generate the types and read what each tool dropped

generate.mjs runs the three generators. json-schema-to-typescript follows $ref itself, relative to the directory it is given, so it reads the separate files. quicktype and datamodel-code-generator read the bundle, the second as a Python module from vendor/, so no console script needs to be on the PATH.

node generate.mjs
generated/order.ts            json-schema-to-typescript 16.0.0  3 types
generated/order.quicktype.ts  quicktype 26.0.0                  4 types
generated/order_models.py     datamodel-codegen 0.85.0       4 classes
export interface Order {
  id: string;
  customer: Customer;
  /**
   * @minItems 1
   */
  lines: [Line, ...Line[]];
  /**
   * @minItems 2
   */
  coordinates?: never[];
  status: "open" | "paid" | "shipped";
  [k: string]: unknown;
}

Two lines in that file are the pitfall. coordinates?: never[] is what a two-number tuple became, and [k: string]: unknown means the object stayed open, so a payload carrying a property the schema forbids passes the type checker. The README of json-schema-to-typescript says its schema model is draft 4 and that a $schema declaration does not change the output.2 The README of quicktype names no draft at all. The datamodel-code-generator documentation has a support table that names drafts 4 to 2020-12 and marks unevaluatedProperties as partial. It emits a tuple type only for a prefixItems with no items and with minItems and maxItems equal to the entries, which the order schema is not. The surest way to learn what each tool makes of a schema is to run it.

probe.mjs does that with one small schema per keyword, and prints the line each generator produced for it.

node probe.mjs
keyword                json-schema-to-typescript                quicktype                                    datamodel-code-generator
prefixItems            coordinates: never[];                    coordinates: [number, number, ...number[]];  coordinates: list[float] = Field(..., max_length=2, min_length=2)
unevaluatedProperties  open: [k: string]: unknown               closed: no index signature                   closed: extra=forbid
$dynamicRef            children?: { [k: string]: unknown; }[];  children?: unknown[];                        children: list[Tree] | None = None

Read it by row. quicktype keeps the two typed positions and adds a rest element, so its tuple accepts a third number the schema rejects. datamodel-code-generator turns the tuple into a list with a length bound and closes the object with extra='forbid', and it resolves $dynamicRef to the recursive type, which neither TypeScript generator does.

Wrap the files in an OpenAPI 3.1 shell when a tool insists

OpenAPI 3.1 says its Schema Object is a superset of JSON Schema 2020-12, so the shell can point at the files without rewriting them.3 openapi.json declares the dialect, one operation, and two components that are references to the files.

  "components": {
    "schemas": {
      "Customer": { "$ref": "./schemas/customer.schema.json" },
      "Order": { "$ref": "./schemas/order.schema.json" }
    }
  }
}
node shell.mjs
openapi 3.1.1, dialect https://json-schema.org/draft/2020-12/schema
components: Customer, Order
external $ref values left: 0
Order keeps its $id: https://schemas.example.com/orders/order.schema.json

Two bases are in play. The shell’s references are relative to the file that holds them, because an OpenAPI document has no $id, and the schemas’ references are relative to their $id. The same resolver serves both, and the bundled document has no external reference left in it. Hand that file to the tool that insisted.

Check it worked

Eight tests with the Node test runner use Ajv in its 2020-12 mode to prove the keywords mean what the files say, and then pin what the generators did with them.

test('unevaluatedProperties: false rejects a property the schema never mentions', () => {
  assert.equal(order({ ...ok, note: 'gift' }), false)
  assert.match(ajv.errorsText(order.errors), /must NOT have unevaluated properties/)
})
node --test schema.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0

# pass 8 is the line to look for. The seventh test asserts that json-schema-to-typescript at the pinned version leaves the order open and the tuple empty. The eighth runs the probe and asserts every generator’s reading of the three keyword schemas. An upgrade that changes any reading fails a test and sends you back to read the output.

When it goes wrong

The bundler fails on a reference you can see is right. pitfall.mjs runs the same bundle without the local resolver, with the network off, and then asks Ajv to compile a tuple schema written without minItems.

node pitfall.mjs
SyntaxError: Unable to resolve $ref pointer "https://schemas.example.com/orders/customer.schema.json"
Ajv strict: strict mode: "prefixItems" is 2-tuple, but minItems or maxItems/items are not specified or different at path "#"

The reference is relative, the base URI is the $id, and the file beside the schema is never consulted. Either serve the files at their $id, or map the base onto the directory as resolve.mjs does. Removing $id makes the error go away at the cost of making the schemas unpublishable, since every reference then depends on where the files sit on disk.

The second line is Ajv refusing a tuple in strict mode. A prefixItems of two entries with no minItems describes an array that may be shorter than its own shape, and Ajv treats that as a mistake rather than a choice. Add minItems, as the order schema does.

A generator produces types without complaint, but the types are wrong. That is the keyword probe’s case: no line in the json-schema-to-typescript output says unevaluatedProperties was ignored. Pin the dialect with $schema anyway, keep the keyword schemas, and let the test that pins each generator’s output be the thing that fails when a version changes.

When not to do this

Do not describe an HTTP API this way when it has endpoints to describe. The shell exists for a tool that insists, and a document whose paths are invented to satisfy a generator is a worse OpenAPI document than one written as OpenAPI from the start. Write the schemas as files if other systems read them, and let the real document reference them under components.schemas.

Do not trust a generator’s silence. The probe shows three tools reading the same three keywords three ways; every one of them exited zero, so success tells you nothing about fidelity. A $schema line is a statement about the file rather than a contract with the reader, so keep the keyword schemas and the pinned-output tests for as long as you generate.

Do not drop $id to make bundling easier. The references then resolve against file paths, which works until someone moves a file or publishes the schemas at a URL, and the resolver in resolve.mjs is a dozen lines.

Do not take generated types for validation. generated/order.ts accepts an order with an extra property that Ajv rejects. The schema is the contract, so validate payloads at the edges with Ajv or Pydantic and treat the types as a convenience for the code in between.

Last verified

Verified 2026-10-21 against Node 22.22.0 and Python 3.13.16, with @apidevtools/json-schema-ref-parser 16.0.5, json-schema-to-typescript 16.0.0, quicktype 26.0.0, Ajv 8.20.0, ajv-formats 3.0.1, and datamodel-code-generator 0.85.0. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci, which also installed the Python generator into vendor/. The TypeSpec emitter was read from its documentation and not run.

Footnotes

  1. The dialect is named for a month and published as an Internet-Draft from a later year. The core specification at json-schema.org is draft-bhutton-json-schema-01, dated June 16, 2022, and its own header says it expired on December 18, 2022. The identifier in every $schema line stayed 2020-12 through that revision, which is the point of naming a dialect rather than a document. ↩︎ Back to text

  2. The json-schema-to-typescript README says the schema model is draft 4, that every draft goes through the same pipeline, and that a $schema declaration does not change the output. Its table of keywords from later drafts marks prefixItems as ignored, and for a schema with items: false it names the never[] that the probe on this page prints. ↩︎ Back to text

  3. OpenAPI 3.1.1 describes its Schema Object as a superset of the JSON Schema Specification Draft 2020-12. It allows $schema in any Schema Object that is a schema resource root, where it MUST be used to determine the dialect. jsonSchemaDialect is the default for schemas that say nothing. The schema files all say something, so on this page the default reaches only the inline schema of the shell’s one path parameter. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.