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

Source: https://voxgig.com/howto/json-schema-alone-as-api-description

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-21
- Published: 2026-10-18

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [OpenAPI 3.1 components.schemas](https://spec.openapis.org/oas/v3.1.1) | The payloads travel over HTTP endpoints you also need to describe, and your tooling reads OpenAPI | A document whose `paths` may be empty, a second base for `$ref` resolution, and the OAS dialect's own keywords layered on the schemas | Nothing has an endpoint, or consumers validate payloads in a queue or a file |
| [Standalone JSON Schema 2020-12 files](https://json-schema.org/draft/2020-12/json-schema-core) | Payloads with no endpoint, shared across languages, validated at the edges by Ajv or Pydantic | No operations, so anything endpoint-shaped needs a shell, and generators built on an earlier draft drop the keywords you rely on | Your tooling reads OpenAPI only, and payloads exist only inside requests |
| [The @typespec/json-schema emitter](https://typespec.io/docs/emitters/json-schema/reference/emitter/) | You already write TypeSpec, or want one source that emits OpenAPI and JSON Schema | A compile step on every contributor's machine, a Node toolchain, and schemas you edit upstream of the files other tools read | The 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.

```json title="schemas/order.schema.json"
{
  "$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](https://json-schema.org/draft/2020-12/json-schema-core) 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`](https://apitools.dev/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.

```js title="resolve.mjs"
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 } }
```

```bash
node bundle.mjs
```

```text output
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`.

```bash
node generate.mjs
```

```text output
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
```

```ts title="generated/order.ts"
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`](https://github.com/bcherny/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`](https://github.com/glideapps/quicktype) names no draft at all. The
[`datamodel-code-generator`](https://datamodel-code-generator.koxudaxi.dev/) documentation has a
[support table](https://datamodel-code-generator.koxudaxi.dev/supported-data-types/) 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.

```bash
node probe.mjs
```

```text output
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.

```json title="openapi.json"
  "components": {
    "schemas": {
      "Customer": { "$ref": "./schemas/customer.schema.json" },
      "Order": { "$ref": "./schemas/order.schema.json" }
    }
  }
}
```

```bash
node shell.mjs
```

```text output
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](https://nodejs.org/api/test.html) use
[Ajv](https://ajv.js.org/json-schema.html) in its 2020-12 mode to prove the keywords mean what the
files say, and then pin what the generators did with them.

```js title="schema.test.mjs"
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/)
})
```

```bash
node --test schema.test.mjs
```

```text output
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`.

```bash
node pitfall.mjs
```

```text output
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.

## Related how-tos

- [Publish an OpenAPI 3.0 copy beside your 3.1 document](/howto/publish-openapi-3-0-alongside-3-1)

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Describe a Kafka topic with AsyncAPI](/howto/describe-a-kafka-topic-with-asyncapi)

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

[^1]: The dialect is named for a month and published as an Internet-Draft from a later year. The
[core specification](https://json-schema.org/draft/2020-12/json-schema-core) 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.

[^2]: The `json-schema-to-typescript` [README](https://github.com/bcherny/json-schema-to-typescript)
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.

[^3]: [OpenAPI 3.1.1](https://spec.openapis.org/oas/v3.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.