From 6aa31a88dd6380dfb2090efae93c83664e8f488e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 05:23:08 +0000 Subject: [PATCH] fix(downgrader): give null its own anyOf branch for several types in 3.0 `type: ["string", "number", "null"]`, as Zod writes for a nullable union of primitives, became an `anyOf` whose branches each carried `nullable: true`. openapi-generator, openapi-zod-client and datamodel-codegen ignore `nullable` inside an `anyOf` branch, so null was lost. The branches now leave it out, and null gets its own branch in the shape `type: "null"` already uses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EfcW36YwHrPJoeGRCsiPyo --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 27 +++++++++---------- .../downgrader/tests/v3.1-to-v3.0.test.ts | 5 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 7e5b030..cf344b9 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -179,7 +179,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | 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"`. | +| [`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. | | [`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. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 18f8c1b..7f8df71 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -440,14 +440,11 @@ function convertType(out: Record, type: unknown): void { out.nullable = true } } else if (rest.length > 1) { - addAnyOf( - out, - rest.map((item) => ({ - type: item, - ...(item === 'array' && { items: out.items ?? {} }), - ...(nullable && { nullable: true }), - })), - ) + // Code generators ignore `nullable` inside an `anyOf` branch, so null gets its own. + addAnyOf(out, [ + ...rest.map((item) => ({ type: item, ...(item === 'array' && { items: out.items ?? {} }) })), + ...(nullable ? [onlyNull()] : []), + ]) delete out.items } else if (nullable) { // 3.0 has no null type, so only null may match, and an `enum` without it matches nothing. @@ -459,6 +456,12 @@ function convertType(out: Record, type: unknown): void { } } +// Only null matches it. 3.0 admits null only through `nullable`, which +// needs a `type` beside it, and code generators drop a bare `enum: [null]`. +function onlyNull(): Record { + return { type: 'string', nullable: true, enum: [null] } +} + function isOnlyNull(values: unknown): boolean { return Array.isArray(values) && values.length === 1 && values[0] === null } @@ -538,14 +541,10 @@ function finishSchema( out.nullable = true loose = true } - // Only null matches it. 3.0 admits null only through `nullable`, which - // needs a `type` beside it, and code generators drop a bare `enum: [null]`. - // `enum` is set again to follow them. + // Only null matches it. `enum` is set again to follow `type` and `nullable`. if (out.type === undefined && isOnlyNull(out.enum)) { delete out.enum - out.type = 'string' - out.nullable = true - out.enum = [null] + Object.assign(out, onlyNull()) } // Code generators drop or leave untyped an `enum` with no `type`, as a // `const` becomes, so it gets the type of its value. 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 6c67754..eb2b290 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -431,8 +431,9 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ maxLength: 3, anyOf: [ - { type: 'array', items: { type: 'number' }, nullable: true }, - { type: 'string', nullable: true }, + { type: 'array', items: { type: 'number' } }, + { type: 'string' }, + { type: 'string', nullable: true, enum: [null] }, ], }) })