From 05bf13fdac2d3428ae2e438dba94606dda4ae92e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:19:29 +0000 Subject: [PATCH 1/2] fix(downgrader): make an anyOf of a null-rejecting branch and null a oneOf in 3.1 to 3.0 A `$ref` or typed `enum` beside the null branch, as Zod `.nullable()` and pydantic `Optional[Model]` write, stays as an `anyOf` pair after #87. NSwag reads that pair as an empty class, but a `oneOf` as the nullable type. Null matches only the null branch and a branch with a type and no `nullable` never matches null, so the `oneOf` means the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp --- packages/downgrader/README.md | 52 +++++++++---------- packages/downgrader/src/v3.1-to-v3.0.ts | 41 ++++++++++++--- .../tests/__snapshots__/orpc.test.ts.snap | 4 +- packages/downgrader/tests/orpc.test.ts | 2 +- .../downgrader/tests/v3.1-to-v3.0.test.ts | 47 +++++++++++++++-- 5 files changed, 105 insertions(+), 41 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d4dd608..70761eb 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -176,32 +176,32 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDocs`, and `example` to JSON Schema 2020-12. Every schema is read as 2020-12, whatever `$schema` or `jsonSchemaDialect` names. -| 3.1 | In 3.0 | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | -| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | -| An [`anyOf`][js-any-of] of `{ type: "null" }` and one branch with a single `type`, as Zod writes a nullable object, array, or integer | That branch with `nullable: true` in place of the `anyOf`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. | -| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | -| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | -| [`examples`][js-examples] | The first entry fills `example` when that is missing. | -| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none. `items: false` becomes `maxItems`. | -| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | -| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | -| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | -| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | -| Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | -| [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | -| [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | -| [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | -| [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | -| [`unevaluatedItems`][js-unevaluated-items] | Removed. | -| An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | -| An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | -| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | -| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | -| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. | -| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. | +| 3.1 | In 3.0 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | +| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | +| An [`anyOf`][js-any-of] of `{ type: "null" }` and one branch with a single `type`, as Zod writes a nullable object, array, or integer | That branch with `nullable: true` in place of the `anyOf`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | +| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | +| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | +| [`examples`][js-examples] | The first entry fills `example` when that is missing. | +| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none. `items: false` becomes `maxItems`. | +| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | +| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | +| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | +| Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | +| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | +| [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | +| [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | +| [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | +| [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | +| [`unevaluatedItems`][js-unevaluated-items] | Removed. | +| An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | +| An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | +| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | +| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | +| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. | +| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. | [js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type [js-const]: https://json-schema.org/draft/2020-12/json-schema-validation#name-const diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 69ad2fb..c10feab 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -132,6 +132,9 @@ const twoBranchAnyOfs: Record[] = [] // and a `nullable` already there was written 3.0-style. const NOT_NULLABLE_KEYWORDS = ['$ref', 'allOf', 'anyOf', 'enum', 'not', 'nullable', 'oneOf'] +// `$ref`s kept as written whose targets reject null, for `mergeNullable`. +const NULL_REJECTING_REFS = new WeakSet() + const convertAnyOf = list(convertSchema) const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) @@ -277,6 +280,10 @@ function convertSchemaRef(ref: string, ctx: Context): unknown { // A `$ref` kept as written loses what its target loses. Converting the // target here reuses the copy the components get, and stops at a cycle. const out = { $ref: ref } + // The target as written, since a cycle can meet its conversion unfinished. + if (rejectsNull(resolvePointer(ref, ctx))) { + NULL_REJECTING_REFS.add(out) + } return isLoose(inline(ref, ctx, convertSchema)) ? loosened(out) : out } const out = inline(ref, ctx, convertSchema) @@ -487,23 +494,41 @@ function isOnlyNullSchema(value: unknown): boolean { ) } +// A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null. +function rejectsNull(schema: unknown): boolean { + if (!isRecord(schema) || schema.nullable === true) { + return false + } + const { type } = schema + return Array.isArray(type) + ? type.length > 0 && type.every((item) => typeof item === 'string' && item !== 'null') + : typeof type === 'string' && type !== 'null' +} + // Zod writes a nullable object, array, or integer as an `anyOf` of it and // `{ type: "null" }`. Code generators read that pair in 3.0 as a union or an // empty class, so the branch takes its place with `nullable: true`, as 3.0 -// writes it, when the schema around it has none of its keywords. +// writes it, when the schema around it has none of its keywords. A branch +// that stays, such as a `$ref` or an `enum`, makes the pair a `oneOf` when it +// rejects null, as null then matches one branch only. NSwag reads that +// `oneOf` as nullable, and the `anyOf` as an empty class. function mergeNullable(out: Record): void { const [first, second] = out.anyOf as unknown[] const other = isOnlyNullSchema(first) ? second : isOnlyNullSchema(second) ? first : undefined + if (!isRecord(other)) { + return + } if ( - !isRecord(other) || - typeof other.type !== 'string' || - NOT_NULLABLE_KEYWORDS.some((key) => Object.hasOwn(other, key)) || - Object.keys(other).some((key) => Object.hasOwn(out, key)) + typeof other.type === 'string' && + !NOT_NULLABLE_KEYWORDS.some((key) => Object.hasOwn(other, key)) && + !Object.keys(other).some((key) => Object.hasOwn(out, key)) ) { - return + delete out.anyOf + Object.assign(out, other, { nullable: true }) + } else if (out.oneOf === undefined && (NULL_REJECTING_REFS.has(other) || rejectsNull(other))) { + out.oneOf = out.anyOf + delete out.anyOf } - delete out.anyOf - Object.assign(out, other, { nullable: true }) } // The type of a JSON value other than null. diff --git a/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap b/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap index 5713679..1e9139a 100644 --- a/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap +++ b/packages/downgrader/tests/__snapshots__/orpc.test.ts.snap @@ -82,7 +82,7 @@ exports[`matches the 3.0 snapshot 1`] = ` "type": "string", }, "parent": { - "anyOf": [ + "oneOf": [ { "$ref": "#/components/schemas/Category", }, @@ -745,7 +745,7 @@ exports[`matches the 3.0 snapshot 1`] = ` "content": { "application/json": { "schema": { - "anyOf": [ + "oneOf": [ { "$ref": "#/components/schemas/Planet", }, diff --git a/packages/downgrader/tests/orpc.test.ts b/packages/downgrader/tests/orpc.test.ts index fa3aabe..ee68a26 100644 --- a/packages/downgrader/tests/orpc.test.ts +++ b/packages/downgrader/tests/orpc.test.ts @@ -53,7 +53,7 @@ it('converts the oRPC document into a valid 3.0 document', async () => { expect(Category).toMatchObject({ properties: { parent: { - anyOf: [ + oneOf: [ { $ref: '#/components/schemas/Category' }, { type: 'string', nullable: true, enum: [null] }, ], diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 5cd40f9..7185a90 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -495,15 +495,23 @@ describe('downgradeSchemaV31ToV30', () => { it('keeps an anyOf with null whose other branch null would not pass, or shares a key', () => { const onlyNull = { type: 'string', nullable: true, enum: [null] } - const branches: OpenAPIV3_1.SchemaObject[] = [ - { $ref: '#/components/schemas/Pet' }, + // Null matches only the null branch here, so it becomes a oneOf. + const typed: OpenAPIV3_1.SchemaObject[] = [ { type: 'string', enum: ['a', 'b'] }, { type: 'object', allOf: [{ required: ['a'] }] }, { type: 'integer', not: { const: 0 } }, + ] + for (const branch of typed) { + expect(downgradeSchemaV31ToV30({ anyOf: [branch, { type: 'null' }] })).toEqual({ + oneOf: [downgradeSchemaV31ToV30(branch), onlyNull], + }) + } + const untyped: OpenAPIV3_1.SchemaObject[] = [ + { $ref: '#/components/schemas/Pet' }, { type: 'string', nullable: true }, { minLength: 1 }, ] - for (const branch of branches) { + for (const branch of untyped) { expect(downgradeSchemaV31ToV30({ anyOf: [branch, { type: 'null' }] })).toEqual({ anyOf: [downgradeSchemaV31ToV30(branch), onlyNull], }) @@ -513,7 +521,16 @@ describe('downgradeSchemaV31ToV30', () => { description: 'A', anyOf: [{ type: 'string', description: 'B' }, { type: 'null' }], }), - ).toEqual({ description: 'A', anyOf: [{ type: 'string', description: 'B' }, onlyNull] }) + ).toEqual({ description: 'A', oneOf: [{ type: 'string', description: 'B' }, onlyNull] }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string', enum: ['a'] }, { type: 'null' }], + oneOf: [{ minLength: 1 }, { maxLength: 0 }], + }), + ).toEqual({ + anyOf: [{ type: 'string', enum: ['a'] }, onlyNull], + oneOf: [{ minLength: 1 }, { maxLength: 0 }], + }) expect( downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], @@ -521,6 +538,28 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ anyOf: [{ type: 'string' }, onlyNull, onlyNull] }) }) + it('makes an anyOf of a $ref and null a oneOf when the target rejects null', () => { + const onlyNull = { type: 'string', nullable: true, enum: [null] } + const pet = (target: OpenAPIV3_1.SchemaObject) => + convert({ + components: { + schemas: { + Pet: target, + Owner: { anyOf: [{ $ref: '#/components/schemas/Pet' }, { type: 'null' }] }, + }, + }, + }).components?.schemas?.Owner + expect(pet({ type: 'object' })).toEqual({ + oneOf: [{ $ref: '#/components/schemas/Pet' }, onlyNull], + }) + expect(pet({ type: ['object', 'null'] })).toEqual({ + anyOf: [{ $ref: '#/components/schemas/Pet' }, onlyNull], + }) + expect(pet({ type: 'object', nullable: true } as OpenAPIV3_1.SchemaObject)).toEqual({ + anyOf: [{ $ref: '#/components/schemas/Pet' }, onlyNull], + }) + }) + it('merges a branch in full where a cycle meets it before its conversion is done', () => { const node: Record = { type: 'object', properties: {} } node.properties.parent = { anyOf: [node, { type: 'null' }] } From 4fb6fc02c29c9231a1187a37fcee362ea3b6a52b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:27:51 +0000 Subject: [PATCH 2/2] docs(downgrader): say the null anyOf row covers any other branch, including a $ref Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp --- packages/downgrader/README.md | 52 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 70761eb..9133402 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -176,32 +176,32 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDocs`, and `example` to JSON Schema 2020-12. Every schema is read as 2020-12, whatever `$schema` or `jsonSchemaDialect` names. -| 3.1 | In 3.0 | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | -| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | -| An [`anyOf`][js-any-of] of `{ type: "null" }` and one branch with a single `type`, as Zod writes a nullable object, array, or integer | That branch with `nullable: true` in place of the `anyOf`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | -| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | -| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | -| [`examples`][js-examples] | The first entry fills `example` when that is missing. | -| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none. `items: false` becomes `maxItems`. | -| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | -| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | -| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | -| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | -| Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | -| [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | -| [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | -| [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | -| [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | -| [`unevaluatedItems`][js-unevaluated-items] | Removed. | -| An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | -| An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | -| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | -| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | -| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. | -| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. | +| 3.1 | In 3.0 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | +| [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | +| An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, or `$ref` | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | +| [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | +| Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | +| [`examples`][js-examples] | The first entry fills `example` when that is missing. | +| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none. `items: false` becomes `maxItems`. | +| [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | +| An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | +| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | +| Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | +| [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | +| [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | +| [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | +| [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | +| [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | +| [`unevaluatedItems`][js-unevaluated-items] | Removed. | +| An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | +| An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | +| Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | +| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | +| [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. | +| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. | [js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type [js-const]: https://json-schema.org/draft/2020-12/json-schema-validation#name-const