From b4a4ecf09f007f9ccd6a36a02b4e66806f81164f Mon Sep 17 00:00:00 2001 From: Jasper Patterson <7529601+jasperpatterson@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:53:12 -0600 Subject: [PATCH] Fix nested oneOf generating uncompilable interface extends on union type --- .../TypeScriptFetchClientCodegen.java | 18 ++++ .../modelGenericInterfaces.mustache | 8 +- .../TypeScriptFetchClientCodegenTest.java | 37 ++++++++ .../3_0/typescript-fetch/nested-oneOf.yaml | 84 +++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java index e66f40d55342..da5e75adfd43 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java @@ -460,6 +460,22 @@ public Map postProcessAllModels(Map objs) } } + // Build a set of classnames that are oneOf models (union types) + Set oneOfModelNames = allModels.stream() + .filter(m -> !m.oneOf.isEmpty()) + .map(m -> m.classname) + .collect(Collectors.toSet()); + + // Mark models whose parent is a oneOf model — these cannot use + // "interface X extends Parent" because TypeScript does not allow + // an interface to extend a union type. They use + // "type X = Parent & { ... }" instead. + for (ExtendedCodegenModel m : allModels) { + if (m.parent != null && oneOfModelNames.contains(m.parent)) { + m.parentIsOneOf = true; + } + } + for (ExtendedCodegenModel rootModel : allModels) { for (String curImport : rootModel.imports) { boolean isModelImport = false; @@ -1545,6 +1561,8 @@ public class ExtendedCodegenModel extends CodegenModel { public Set oneOfPrimitives = new HashSet<>(); @Getter @Setter public CodegenDiscriminator.MappedModel selfReferencingDiscriminatorMapping; + @Getter @Setter + public boolean parentIsOneOf; // true when this model's parent is a oneOf union type public boolean isEntity; // Is a model containing an "id" property marked as isUniqueId public String returnPassthrough; diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache index b070bf9c07e0..47982aafd3e3 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache @@ -1,9 +1,15 @@ /** * {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}} * @export +{{^parentIsOneOf}} * @interface {{classname}} */ export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{ +{{/parentIsOneOf}} +{{#parentIsOneOf}} + */ +export type {{classname}} = {{{parent}}} & { +{{/parentIsOneOf}} {{#additionalPropertiesType}} [key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}}; {{/additionalPropertiesType}} @@ -18,7 +24,7 @@ export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{ */ {{#isReadOnly}}readonly {{/isReadOnly}}{{name}}{{^required}}?{{/required}}: {{{datatypeWithEnum}}}{{#isNullable}} | null{{/isNullable}}; {{/vars}} -}{{#hasEnums}} +}{{#parentIsOneOf}};{{/parentIsOneOf}}{{#hasEnums}} {{#vars}} {{#isEnum}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java index da183535f3ad..5c68ce4e9fd5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java @@ -521,6 +521,43 @@ public void testRequestOptsNotInInterfaceWhenDisabled() throws IOException { assertThat(classSection).contains("async addPetRequestOpts("); } + /** + * When a oneOf variant uses allOf to reference another oneOf (nested discriminated unions), + * the child model must be generated as a type alias with intersection rather than an + * interface with extends, because TypeScript does not allow interfaces to extend union types. + */ + @Test(description = "Verify nested oneOf generates type alias instead of interface extends") + public void testNestedOneOfGeneratesTypeAliasForOneOfParent() throws IOException { + File output = generate( + Collections.emptyMap(), + "src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml" + ); + + // OuterComposed's parent is Inner (a oneOf union type), so it must use + // "type OuterComposed = Inner & { ... }" instead of "interface OuterComposed extends Inner" + Path outerComposed = Paths.get(output + "/models/OuterComposed.ts"); + TestUtils.assertFileExists(outerComposed); + TestUtils.assertFileContains(outerComposed, "export type OuterComposed = Inner & {"); + TestUtils.assertFileNotContains(outerComposed, "export interface OuterComposed extends Inner"); + + // Inner should still be a proper oneOf union type with discriminator dispatch + Path inner = Paths.get(output + "/models/Inner.ts"); + TestUtils.assertFileExists(inner); + TestUtils.assertFileContains(inner, "export type Inner = { innerDiscriminator: 'a' } & InnerA | { innerDiscriminator: 'b' } & InnerB"); + TestUtils.assertFileContains(inner, "switch (json['innerDiscriminator'])"); + + // Outer should dispatch on outerDiscriminator, including the composed variant + Path outer = Paths.get(output + "/models/Outer.ts"); + TestUtils.assertFileExists(outer); + TestUtils.assertFileContains(outer, "switch (json['outerDiscriminator'])"); + TestUtils.assertFileContains(outer, "case 'composed':"); + + // Regular models (not extending a oneOf parent) should still use interface + Path outerPlain = Paths.get(output + "/models/OuterPlain.ts"); + TestUtils.assertFileExists(outerPlain); + TestUtils.assertFileContains(outerPlain, "export interface OuterPlain {"); + } + private static File generate( Map properties ) throws IOException { diff --git a/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml new file mode 100644 index 000000000000..9a3dafcd2519 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml @@ -0,0 +1,84 @@ +openapi: "3.0.3" +info: + title: Nested OneOf Test + description: > + Tests that a oneOf variant referencing another oneOf via allOf generates + correct TypeScript types. The outer union (Outer) is discriminated by + "outerDiscriminator"; one of its variants (OuterComposed) uses allOf to + compose a fixed discriminator value with a $ref to an inner union (Inner) + discriminated by "innerDiscriminator". A plain variant (OuterPlain) is + included to verify normal interface generation is unaffected. + version: "1.0" +paths: + /items: + get: + operationId: getItems + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Outer" +components: + schemas: + # ── Outer oneOf (discriminated by "outerDiscriminator") ────────────── + Outer: + oneOf: + - $ref: "#/components/schemas/OuterPlain" + - $ref: "#/components/schemas/OuterComposed" + discriminator: + propertyName: outerDiscriminator + mapping: + plain: "#/components/schemas/OuterPlain" + composed: "#/components/schemas/OuterComposed" + + OuterPlain: + type: object + required: [outerDiscriminator, plainValue] + properties: + outerDiscriminator: + type: string + plainValue: + type: string + + # Uses allOf to merge a fixed discriminator value with a nested oneOf ref + OuterComposed: + allOf: + - type: object + required: [outerDiscriminator] + properties: + outerDiscriminator: + type: string + - $ref: "#/components/schemas/Inner" + + # ── Inner oneOf (discriminated by "innerDiscriminator") ───────────── + Inner: + oneOf: + - $ref: "#/components/schemas/InnerA" + - $ref: "#/components/schemas/InnerB" + discriminator: + propertyName: innerDiscriminator + mapping: + a: "#/components/schemas/InnerA" + b: "#/components/schemas/InnerB" + + InnerA: + type: object + required: [innerDiscriminator, fieldA] + properties: + innerDiscriminator: + type: string + fieldA: + type: string + + InnerB: + type: object + required: [innerDiscriminator, fieldB] + properties: + innerDiscriminator: + type: string + fieldB: + type: integer