You moved the spec to OpenAPI 3.1, but a partner’s pinned generator only reads 3.0. You run a
down-converter, it exits 0, and the copy ships. Weeks later their client accepts a total of
zero, because the converter left exclusiveMinimum: 0 in place. A 3.0 reader treats that as a
boolean, so the bound disappeared.
What you get
You will end up with a CI step that builds a 3.0 copy from your 3.1 source, refuses any loss you did not name, and labels the copy as derived. This is for you if one consumer’s tooling cannot read 3.1 and you will not give up 3.1 for everyone else.
Short answer
Keep the 3.1 document canonical and generate the 3.0 copy in CI with
@apiture/openapi-down-convert, wrapped in a guard. The guard rewrites numeric
exclusiveMinimum, refuses keywords 3.0 cannot express unless you name them, fails on 3.1
constructs the converter leaves behind, and stamps the canonical URL into the copy. Then run your
oldest generator against the copy and openapi-typescript against the source.
You will need
Node 22 or later and an OpenAPI 3.1 document. Verified against Node 22.22.0. The source here uses type arrays for null, the shape the page on nullable and optional fields in OpenAPI 3.1 sets up. The 3.0.3 Schema Object is a subset of an older JSON Schema draft,1 and anything it does not list is, in its words, “strictly unsupported.”
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
@apiture/openapi-down-convert | A hand-written or generated 3.1 file, and a build step you control | A guard you write and own, because it exits 0 on output a 3.0 reader rejects | Your framework already emits both versions from code |
| Huma dual output | Your API is written in Go with Huma, which serves 3.1 and 3.0.3 side by side | A Go framework, and its rules for type arrays, which keep the last non-null type | The spec is written by hand, or the service is not Go |
| A hand-kept 3.0-compatible subset | Few schemas, and no 3.1 feature you need yet | You give up webhooks, const and null in type arrays, the features that justified 3.1 | You adopted 3.1 for one of those features |
The converter is the only option that keeps one hand-written source and a mechanical copy, but
it fails silently, so it needs the guard below. Huma writes exclusiveMinimum in the 3.0 form,
but only for specs it generated itself. A hand-kept subset needs no build step, at the price of writing
3.0 forever with a 3.1 version number on it.
Wrap the converter in a guard
The converter does most of the work but reports it only when --verbose is on. Run against
this page’s sample, version 0.14.2 dropped webhooks and propertyNames, rewrote the type array
and const, and left exclusiveMinimum: 0 untouched. It exited 0 each time.
The guard below checks the source before conversion and the copy after it. It fails on any
constraint, type or data value it cannot translate exactly. Labels go through: a $ref’s sibling
description, every examples entry after the first, $comment, and the license identifier.
export function downConvert(doc31, { canonicalUrl, allowLoss = [] } = {}) {
if (!/^3\.1\.\d+$/.test(String(doc31.openapi))) {
throw new DownConvertError(`expected an OpenAPI 3.1 document, got ${doc31.openapi}`, [])
}
if (!canonicalUrl) {
throw new DownConvertError('no canonical URL: the 3.0 copy must say where the source of truth is', [])
}
const lossy = lossyKeywords(doc31)
const refused = lossy.filter((l) => !allowLoss.includes(l.keyword))
if (refused.length) {
throw new DownConvertError('3.0 cannot express these, and allowLoss does not name them', refused)
}
const webhooks = Object.keys(doc31.webhooks ?? {})
const doc = new Converter(structuredClone(doc31), { verbose: false }).convert()
const rewrites = fixExclusiveBounds(doc)
const left = [...changedProperties(doc31, doc), ...changedData(doc31, doc), ...leftovers(doc)]
if (left.length) {
throw new DownConvertError('the 3.0 copy would not say what the source says', left)
}
const note = `Generated from the canonical OpenAPI 3.1 document at ${canonicalUrl}. ` +
'Report problems against that document, not this copy.'
doc.info.description = doc.info.description ? `${note}\n\n${doc.info.description}` : note
doc.info['x-derived-from'] = canonicalUrl
return { doc, report: { lost: lossy, webhooks, rewrites } }
}
fixExclusiveBounds turns exclusiveMinimum: 0 into minimum: 0 with exclusiveMinimum: true,
which is how 3.0 writes the same bound.2 When both bounds are set it keeps the stricter one.
leftovers walks every schema for type arrays, type: 'null', const, prefixItems, a $ref
with siblings and the other 2020-12 keywords. It skips property names, so a field called if is
not a keyword. changedData compares every example, default and enum value before and after,
because the converter walks those values as though they were schemas and rewrites an examples
array or a const key it finds inside one.
Build the copy in CI
Run the build on every change to the source. The first run stops, because the labels map
constrains its keys with propertyNames and 3.0 has nothing that says the same thing.
node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json
refused: 3.0 cannot express these, and allowLoss does not name them
propertyNames at /components/schemas/Order/properties/labels
Decide, then say so in the command. Here the key pattern is a server-side check that a 3.0 client can live without.
node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json --allow-loss propertyNames
spec/openapi.yaml (3.1.0) -> public/openapi-3.0.yaml (3.0.3)
canonical https://api.example.com/v2/openapi.json
dropped webhook orderShipped: 3.0 has no webhooks
lost propertyNames at /components/schemas/Order/properties/labels (allowed)
rewrote exclusiveMinimum: 0 at /components/schemas/Order/properties/total
Publish the copy at a URL that names its version, and keep the canonical one at the plain
address. The copy’s info.description opens with the canonical URL, and x-derived-from carries
it for tools. A consumer who files a bug quotes a document that tells them where to file it.
Check it worked
Run the slowest-moving generator you support against the copy, and a 3.1-native one against the source. openapi-typescript-codegen is unmaintained and points users at Hey API, which makes it a fair stand-in for the pinned tool. openapi-typescript reads 3.1 directly.
node generate.mjs
generator input couponCode schemaVersion file
openapi-typescript-codegen 3.1 source string | null any string
openapi-typescript-codegen 3.0 copy string | null Order.schemaVersion Blob
openapi-typescript 3.1 source string | null 2 string
Read the first row before trusting a generator’s silence. Fed the 3.1 file, the old generator did
not reject it. It typed const as any and the upload as string, so the generated method
will not take a Blob without a cast. From the copy it produced Blob and an enum. openapi-typescript
types binary as string by default, and its Node API documents a
transform to Blob.
Then lint the copy against the 3.0 schema. This configuration turns on only
Redocly’s struct rule and reference
resolution, so style warnings stay out of the result.
rules:
struct: error
no-unresolved-refs: error
npx redocly lint public/openapi-3.0.yaml --format=stylish 2>&1
Woohoo! Your API description is valid. 🎉
node --test downconvert.test.mjs
1..15
# tests 15
# suites 0
# pass 15
# fail 0
Each test feeds the guard shapes the converter mishandles and asserts the exact pointers it reports.
When it goes wrong
The converter exits 0 on a copy that a 3.0 validator rejects. Run the converter without the guard and lint what it wrote.
npx openapi-down-convert -i spec/openapi.yaml -o naive-3.0.yaml && npx redocly lint naive-3.0.yaml --format=stylish
naive-3.0.yaml:
74:29 error struct Expected type `boolean` but got `integer`.
That is the total bound from the opening paragraph. Lint every copy before publishing it.
A field disappears from the copy. The converter walks a properties map as though it were a
schema, so a property named after a keyword is treated as one.
npx openapi-down-convert -i spec/keyword-names.yaml
components:
schemas:
Rule:
type: object
properties:
kind:
type: string
enum:
- type: string
The source had $schema, const and kind. The copy deleted $schema, a property Huma’s
schema link transformer adds to
its response models, and renamed const to enum. The guard compares property names before and
after, so the same input fails with property $schema lost, property const lost and
property enum added.
The nullable $ref written as oneOf: [{ $ref }, { type: 'null' }] comes through unchanged and
fails 3.0. There is no exact rewrite, because a 3.0
Reference Object ignores anything
written beside it, nullable included. Written instead as a $ref with type: [object, 'null']
beside it, the reference converts to nullable: true beside the $ref, which the same rule
hides from a 3.0 reader. The guard refuses both forms, and any other $ref sibling that is more
than a label, so a person decides what the 3.0 client should assume.
When not to do this
Do not publish both documents without saying which one is canonical. Consumers report bugs against whichever one their tool fetched. A description that names the source turns those reports into pointers rather than arguments.
Do not let the copy become a second source. Never edit public/openapi-3.0.yaml by hand, because
the build overwrites it on every run.
Do not add --allow-loss to make a red build green. Each allowed keyword is a rule the 3.0
client will not enforce, so name it only after deciding the server-side check is enough.
Do not keep the copy forever. Ask the consumer which generator version would read 3.1, and give
the copy a removal date. If your service is written with Huma, use its own /openapi-3.0.json
rather than this pipeline, because the framework has the types the converter has to guess.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, @apiture/openapi-down-convert 0.14.2, @redocly/cli
2.54.3, openapi-typescript 7.13.0 and openapi-typescript-codegen 0.31.0. Every output block is
what the preceding command printed in the page’s code directory after npm ci. The Huma behavior was
read from its source and observed with v2.39.1 under Go locally, outside the captured runs.
Footnotes
-
The 3.0.3 specification calls it “JSON Schema Specification Wright Draft 00.” That names the draft after its editor, Austin Wright, as the Internet-Draft’s header shows. The JSON Schema project’s own specification links file that draft as Draft 5, a cleanup of Draft 4 that kept Draft 4’s meta-schemas. Its draft-06 release notes then ask implementers not to implement or advertise support for draft-05. OpenAPI 3.0 builds on it all the same, by the name that leaves the number out. ↩︎ Back to text
-
The boolean form is the older one. JSON Schema draft-06 changed both keywords from a boolean to a number. Its release notes give the reason as “the principle of keyword independence.” Since then
exclusiveMinimumhas not neededminimumbeside it to mean anything, so a down-converter has to put the neighbor back. The Huma source does, and on type arrays it settles the matter with a comment: “Last non-null wins.” ↩︎ Back to text