From 1c103e7adffbf167e483279d07344c83dac0bc76 Mon Sep 17 00:00:00 2001 From: Wotan Date: Tue, 10 Feb 2026 18:02:19 +0000 Subject: [PATCH] fix: anyOf with sibling properties generates intersection instead of union When a schema has both anyOf and properties at the same level, the properties should be intersected with the anyOf union, not included as another union member. Before: { name?: string; } | Type1 | Type2 After: { name?: string; } & (Type1 | Type2) This matches the OpenAPI spec semantics where sibling properties apply to all anyOf variants. Closes #2380 --- .changeset/fix-anyof-with-properties.md | 5 +++ .../github-api-export-type-immutable.ts | 10 +++--- .../examples/github-api-immutable.ts | 10 +++--- .../examples/github-api-next.ts | 16 ++++----- .../examples/github-api-required.ts | 10 +++--- .../examples/github-api-root-types.ts | 10 +++--- .../openapi-typescript/examples/github-api.ts | 10 +++--- .../src/transform/schema-object.ts | 15 +++++--- .../schema-object/composition.test.ts | 34 +++++++++++++++++++ 9 files changed, 83 insertions(+), 37 deletions(-) create mode 100644 .changeset/fix-anyof-with-properties.md diff --git a/.changeset/fix-anyof-with-properties.md b/.changeset/fix-anyof-with-properties.md new file mode 100644 index 000000000..d23753e5a --- /dev/null +++ b/.changeset/fix-anyof-with-properties.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Fix anyOf with sibling properties to generate intersection instead of union diff --git a/packages/openapi-typescript/examples/github-api-export-type-immutable.ts b/packages/openapi-typescript/examples/github-api-export-type-immutable.ts index 4ccd073d1..f8a6459f9 100644 --- a/packages/openapi-typescript/examples/github-api-export-type-immutable.ts +++ b/packages/openapi-typescript/examples/github-api-export-type-immutable.ts @@ -105595,7 +105595,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ readonly identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ readonly status?: "completed"; } & { @@ -105605,7 +105605,7 @@ export interface operations { readonly status?: "queued" | "in_progress"; } & { readonly [key: string]: unknown; - }); + })); }; }; readonly responses: { @@ -113373,7 +113373,7 @@ export interface operations { */ readonly path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; readonly responses: { @@ -113420,7 +113420,7 @@ export interface operations { */ readonly path?: "/" | "/docs"; }; - } | unknown | unknown) | null; + } & (unknown | unknown)) | null; }; }; readonly responses: { @@ -114769,7 +114769,7 @@ export interface operations { readonly reviewers?: readonly string[]; /** @description An array of team `slug`s that will be requested. */ readonly team_reviewers?: readonly string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; readonly responses: { diff --git a/packages/openapi-typescript/examples/github-api-immutable.ts b/packages/openapi-typescript/examples/github-api-immutable.ts index 2c9ec9141..4075b40b4 100644 --- a/packages/openapi-typescript/examples/github-api-immutable.ts +++ b/packages/openapi-typescript/examples/github-api-immutable.ts @@ -105595,7 +105595,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ readonly identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ readonly status?: "completed"; } & { @@ -105605,7 +105605,7 @@ export interface operations { readonly status?: "queued" | "in_progress"; } & { readonly [key: string]: unknown; - }); + })); }; }; readonly responses: { @@ -113373,7 +113373,7 @@ export interface operations { */ readonly path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; readonly responses: { @@ -113420,7 +113420,7 @@ export interface operations { */ readonly path?: "/" | "/docs"; }; - } | unknown | unknown) | null; + } & (unknown | unknown)) | null; }; }; readonly responses: { @@ -114769,7 +114769,7 @@ export interface operations { readonly reviewers?: readonly string[]; /** @description An array of team `slug`s that will be requested. */ readonly team_reviewers?: readonly string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; readonly responses: { diff --git a/packages/openapi-typescript/examples/github-api-next.ts b/packages/openapi-typescript/examples/github-api-next.ts index 2d47a1d10..7a3f245f9 100644 --- a/packages/openapi-typescript/examples/github-api-next.ts +++ b/packages/openapi-typescript/examples/github-api-next.ts @@ -25412,7 +25412,7 @@ export interface components { * @example 1 */ id: number; - account: (null | (components["schemas"]["simple-user"] | components["schemas"]["enterprise"])) | components["schemas"]["simple-user"] | components["schemas"]["enterprise"]; + account: (null | (components["schemas"]["simple-user"] | components["schemas"]["enterprise"])) & (components["schemas"]["simple-user"] | components["schemas"]["enterprise"]); /** * @description Describe whether all repositories have been selected or there's a selection involved * @enum {string} @@ -31366,7 +31366,7 @@ export interface components { href?: string; } | null; }; - conditions?: (null | (components["schemas"]["repository-ruleset-conditions"] | components["schemas"]["org-ruleset-conditions"])) | components["schemas"]["repository-ruleset-conditions"] | components["schemas"]["org-ruleset-conditions"]; + conditions?: (null | (components["schemas"]["repository-ruleset-conditions"] | components["schemas"]["org-ruleset-conditions"])) & (components["schemas"]["repository-ruleset-conditions"] | components["schemas"]["org-ruleset-conditions"]); rules?: components["schemas"]["repository-rule"][]; /** Format: date-time */ created_at?: string; @@ -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 & (string | number | boolean)) | (number & (string | number | boolean)) | (boolean & (string | number | boolean))) & (string | number | boolean); }; dependency: { /** @@ -109814,7 +109814,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ status?: "completed"; } & { @@ -109824,7 +109824,7 @@ export interface operations { status?: "queued" | "in_progress"; } & { [key: string]: unknown; - }); + })); }; }; responses: { @@ -117592,7 +117592,7 @@ export interface operations { */ path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; responses: { @@ -117639,7 +117639,7 @@ export interface operations { */ path?: "/" | "/docs"; }; - } | unknown | unknown) | null) | unknown | unknown; + } & (unknown | unknown)) | null) & (unknown | unknown); }; }; responses: { @@ -118988,7 +118988,7 @@ export interface operations { reviewers?: string[]; /** @description An array of team `slug`s that will be requested. */ team_reviewers?: string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; responses: { diff --git a/packages/openapi-typescript/examples/github-api-required.ts b/packages/openapi-typescript/examples/github-api-required.ts index 3bb28b0c1..117972faf 100644 --- a/packages/openapi-typescript/examples/github-api-required.ts +++ b/packages/openapi-typescript/examples/github-api-required.ts @@ -105595,7 +105595,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ status?: "completed"; } & { @@ -105605,7 +105605,7 @@ export interface operations { status: "queued" | "in_progress"; } & { [key: string]: unknown; - }); + })); }; }; responses: { @@ -113373,7 +113373,7 @@ export interface operations { */ path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; responses: { @@ -113420,7 +113420,7 @@ export interface operations { */ path?: "/" | "/docs"; }; - } | unknown | unknown) | null; + } & (unknown | unknown)) | null; }; }; responses: { @@ -114769,7 +114769,7 @@ export interface operations { reviewers: string[]; /** @description An array of team `slug`s that will be requested. */ team_reviewers: string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; responses: { diff --git a/packages/openapi-typescript/examples/github-api-root-types.ts b/packages/openapi-typescript/examples/github-api-root-types.ts index 18af3fe0a..2eca9c229 100644 --- a/packages/openapi-typescript/examples/github-api-root-types.ts +++ b/packages/openapi-typescript/examples/github-api-root-types.ts @@ -106622,7 +106622,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ status?: "completed"; } & { @@ -106632,7 +106632,7 @@ export interface operations { status?: "queued" | "in_progress"; } & { [key: string]: unknown; - }); + })); }; }; responses: { @@ -114400,7 +114400,7 @@ export interface operations { */ path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; responses: { @@ -114447,7 +114447,7 @@ export interface operations { */ path?: "/" | "/docs"; }; - } | unknown | unknown) | null; + } & (unknown | unknown)) | null; }; }; responses: { @@ -115796,7 +115796,7 @@ export interface operations { reviewers?: string[]; /** @description An array of team `slug`s that will be requested. */ team_reviewers?: string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; responses: { diff --git a/packages/openapi-typescript/examples/github-api.ts b/packages/openapi-typescript/examples/github-api.ts index c63dd71bc..de5d4547e 100644 --- a/packages/openapi-typescript/examples/github-api.ts +++ b/packages/openapi-typescript/examples/github-api.ts @@ -105595,7 +105595,7 @@ export interface operations { /** @description A reference for the action on the integrator's system. The maximum size is 20 characters. */ identifier: string; }[]; - } | ({ + } & (({ /** @enum {unknown} */ status?: "completed"; } & { @@ -105605,7 +105605,7 @@ export interface operations { status?: "queued" | "in_progress"; } & { [key: string]: unknown; - }); + })); }; }; responses: { @@ -113373,7 +113373,7 @@ export interface operations { */ path: "/" | "/docs"; }; - } | unknown | unknown | unknown | unknown | unknown; + } & (unknown | unknown | unknown | unknown | unknown); }; }; responses: { @@ -113420,7 +113420,7 @@ export interface operations { */ path?: "/" | "/docs"; }; - } | unknown | unknown) | null; + } & (unknown | unknown)) | null; }; }; responses: { @@ -114769,7 +114769,7 @@ export interface operations { reviewers?: string[]; /** @description An array of team `slug`s that will be requested. */ team_reviewers?: string[]; - } | unknown | unknown; + } & (unknown | unknown); }; }; responses: { diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 8621bfc47..ae55ee020 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -280,10 +280,17 @@ export function transformSchemaObjectWithComposition( finalType = tsIntersection([...(coreObjectType ? [coreObjectType] : []), ...(allOf ? [allOf] : [])]); } // anyOf: union - // (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf) + // (note: this may seem counterintuitive, but as TypeScript's unions are not true XORs, they mimic behavior closer to anyOf than oneOf) + // When there are sibling properties alongside anyOf, they should be intersected with the union (issue #2380) const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf"); if (anyOfType.length) { - finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]); + if (finalType) { + // If there are sibling properties, intersect them with the anyOf union + finalType = tsIntersection([finalType, tsUnion(anyOfType)]); + } else { + // If no sibling properties, just use the union + finalType = tsUnion(anyOfType); + } } // oneOf: union (within intersection with other types, if any) const oneOfType = collectUnionCompositions( @@ -472,7 +479,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor t === "null" || t === null ? NULL : transformSchemaObject( - { ...schemaObject, type: t, oneOf: undefined } as SchemaObject, // don’t stack oneOf transforms + { ...schemaObject, type: t, oneOf: undefined } as SchemaObject, // don't stack oneOf transforms options, ), ); @@ -558,7 +565,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor options.ctx.defaultNonNullable && !options.path?.includes("parameters") && !options.path?.includes("requestBody") && - !options.path?.includes("requestBodies")) // can’t be required, even with defaults + !options.path?.includes("requestBodies")) // can't be required, even with defaults ? undefined : QUESTION_TOKEN; let type = $ref 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..dd3bfa7c8 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -612,6 +612,40 @@ describe("composition", () => { // options: DEFAULT_OPTIONS }, ], + [ + "anyOf > with sibling properties (issue #2380)", + { + given: { + anyOf: [ + { + type: "object", + properties: { type: { type: "string", enum: ["email"] }, address: { type: "string" } }, + required: ["type", "address"], + }, + { + type: "object", + properties: { type: { type: "string", enum: ["phone"] }, number: { type: "string" } }, + required: ["type", "number"], + }, + ], + properties: { + name: { type: "string" }, + }, + }, + want: `{ + name?: string; +} & ({ + /** @enum {string} */ + type: "email"; + address: string; +} | { + /** @enum {string} */ + type: "phone"; + number: string; +})`, + // options: DEFAULT_OPTIONS + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {