From ffbfebbabc4426453b31c638215ba841f0acc676 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Wed, 26 Aug 2026 15:18:18 +0200 Subject: [PATCH 1/2] fix(openapi-typescript): don't stack anyOf/allOf transforms in type-array recursion transformSchemaObjectCore spreads the full schema object into each per-type recursive call, so a sibling anyOf/allOf is transformed once per primitive type member and again at the top level, yielding redundant nested unions like ((string | null) | null) | string | null. Extend the existing `oneOf: undefined` guard to anyOf/allOf, but only for primitive members. object/array members need the composition, since stripping it collapses the core to Record and widens the union. --- .changeset/eager-moles-wash.md | 5 +++ .../examples/github-api-next.ts | 2 +- .../src/transform/schema-object.ts | 13 +++++- .../schema-object/composition.test.ts | 44 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .changeset/eager-moles-wash.md diff --git a/.changeset/eager-moles-wash.md b/.changeset/eager-moles-wash.md new file mode 100644 index 000000000..cea0e9419 --- /dev/null +++ b/.changeset/eager-moles-wash.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Fix redundant nested unions from a type array with a sibling `anyOf`/`allOf`. diff --git a/packages/openapi-typescript/examples/github-api-next.ts b/packages/openapi-typescript/examples/github-api-next.ts index 2d47a1d10..ac2efcf5f 100644 --- a/packages/openapi-typescript/examples/github-api-next.ts +++ b/packages/openapi-typescript/examples/github-api-next.ts @@ -35615,7 +35615,7 @@ export interface components { * @description User-defined metadata to store domain-specific information limited to 8 keys with scalar values. */ metadata: { - [key: string]: (null | (string | number | boolean) | (number | string | boolean) | (boolean | string | number)) | string | number | boolean; + [key: string]: (null | string | number | boolean) | string | number | boolean; }; dependency: { /** diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index caab5e10f..d7baebaef 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -489,7 +489,18 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (t === "null" || t === null) { uniqueTypes.push(NULL); } else { - uniqueTypes.push(transformSchemaObject({ ...schemaObject, type: t } as SchemaObject, options)); + // 'type' alone fully describes a primitive member, so re-running anyOf/allOf + // here only stacks the composition the caller already applies. object/array + // members keep it: there, composition is what carries the shape. + const stackable = t === "object" || t === "array"; + uniqueTypes.push( + transformSchemaObject( + (stackable + ? { ...schemaObject, type: t } + : { ...schemaObject, type: t, anyOf: undefined, allOf: undefined }) as SchemaObject, + options, + ), + ); } } } diff --git a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts index 72e8cebb8..25acf70a6 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -53,6 +53,50 @@ describe("composition", () => { // options: DEFAULT_OPTIONS, }, ], + [ + "polymorphic > anyOf + nullable", + { + given: { + type: ["string", "null"], + anyOf: [{ type: "string", format: "ipv4" }, { type: "string", format: "ipv6" }, { type: "null" }], + }, + want: "(string | null) | string | null", + // options: DEFAULT_OPTIONS, + }, + ], + [ + "polymorphic > allOf + nullable", + { + given: { + type: ["string", "null"], + allOf: [{ type: "string" }], + }, + want: "(string | null) & string", + // options: DEFAULT_OPTIONS, + }, + ], + [ + "polymorphic > anyOf + nullable object", + { + given: { + type: ["null", "object"], + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }, + want: `(null | ({ + a?: string; +} | { + b?: string; +})) | { + a?: string; +} | { + b?: string; +}`, + // options: DEFAULT_OPTIONS, + }, + ], [ "oneOf > primitives", { From f1420f62193cce889e2e95c1c05793a332c61fd0 Mon Sep 17 00:00:00 2001 From: Oleg Kamlowski Date: Wed, 26 Aug 2026 16:18:52 +0200 Subject: [PATCH 2/2] fix(openapi-typescript): extend anyOf/allOf guard to the oneOf branch that branch stripped only oneOf, so a sibling anyOf/allOf still stacked. Both branches now share one transformTypeMember helper. --- .changeset/eager-moles-wash.md | 2 +- .../src/transform/schema-object.ts | 42 ++++++++----------- .../schema-object/composition.test.ts | 34 +++++++++++++++ 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/.changeset/eager-moles-wash.md b/.changeset/eager-moles-wash.md index cea0e9419..7ad24de02 100644 --- a/.changeset/eager-moles-wash.md +++ b/.changeset/eager-moles-wash.md @@ -2,4 +2,4 @@ "openapi-typescript": patch --- -Fix redundant nested unions from a type array with a sibling `anyOf`/`allOf`. +Fix redundant nested unions from a type array with a sibling `anyOf`/`allOf`, including when a `oneOf` sits alongside them. diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index d7baebaef..e5108fc3d 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -467,6 +467,22 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (Array.isArray(schemaObject.type) && !Array.isArray(schemaObject)) { // skip any primitive types that appear in oneOf as well const uniqueTypes: ts.TypeNode[] = []; + // The caller transforms the sibling composition keywords itself, so drop them here + // or each type member re-applies them. 'type' alone fully describes a primitive + // member; object/array members keep anyOf/allOf, because there the composition is + // what carries the shape and stripping it leaves Record / unknown[]. + const transformTypeMember = (t: string) => { + const stackable = t === "object" || t === "array"; + return transformSchemaObject( + { + ...schemaObject, + type: t, + oneOf: undefined, + ...(stackable ? {} : { anyOf: undefined, allOf: undefined }), + } as SchemaObject, + options, + ); + }; if (Array.isArray(schemaObject.oneOf)) { for (const t of schemaObject.type) { if ( @@ -475,33 +491,11 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor ) { continue; } - uniqueTypes.push( - t === "null" || t === null - ? NULL - : transformSchemaObject( - { ...schemaObject, type: t, oneOf: undefined } as SchemaObject, // don’t stack oneOf transforms - options, - ), - ); + uniqueTypes.push(t === "null" || t === null ? NULL : transformTypeMember(t)); } } else { for (const t of schemaObject.type) { - if (t === "null" || t === null) { - uniqueTypes.push(NULL); - } else { - // 'type' alone fully describes a primitive member, so re-running anyOf/allOf - // here only stacks the composition the caller already applies. object/array - // members keep it: there, composition is what carries the shape. - const stackable = t === "object" || t === "array"; - uniqueTypes.push( - transformSchemaObject( - (stackable - ? { ...schemaObject, type: t } - : { ...schemaObject, type: t, anyOf: undefined, allOf: undefined }) as SchemaObject, - options, - ), - ); - } + uniqueTypes.push(t === "null" || t === null ? NULL : transformTypeMember(t)); } } return tsUnion(uniqueTypes); diff --git a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts index 25acf70a6..7d65a3de0 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -75,6 +75,40 @@ describe("composition", () => { // options: DEFAULT_OPTIONS, }, ], + [ + "polymorphic > allOf + nullable, multiple members", + { + given: { + type: ["string", "null"], + allOf: [{ type: "string" }, { type: "string", format: "uuid" }], + }, + want: "(string | null) & (string)", + // options: DEFAULT_OPTIONS, + }, + ], + [ + "polymorphic > anyOf + nullable array", + { + given: { + type: ["array", "null"], + anyOf: [{ type: "array", items: { type: "string" } }], + }, + want: "((unknown[] | string[]) | null) | string[]", + // options: DEFAULT_OPTIONS, + }, + ], + [ + "polymorphic > oneOf + allOf + nullable", + { + given: { + type: ["string", "null"], + oneOf: [{ type: "number" }], + allOf: [{ type: "string" }], + }, + want: "((string | null) & string) | number", + // options: DEFAULT_OPTIONS, + }, + ], [ "polymorphic > anyOf + nullable object", {