Skip to content

Commit b4a4ecf

Browse files
Fix nested oneOf generating uncompilable interface extends on union type
1 parent 7973088 commit b4a4ecf

4 files changed

Lines changed: 146 additions & 1 deletion

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,22 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
460460
}
461461
}
462462

463+
// Build a set of classnames that are oneOf models (union types)
464+
Set<String> oneOfModelNames = allModels.stream()
465+
.filter(m -> !m.oneOf.isEmpty())
466+
.map(m -> m.classname)
467+
.collect(Collectors.toSet());
468+
469+
// Mark models whose parent is a oneOf model — these cannot use
470+
// "interface X extends Parent" because TypeScript does not allow
471+
// an interface to extend a union type. They use
472+
// "type X = Parent & { ... }" instead.
473+
for (ExtendedCodegenModel m : allModels) {
474+
if (m.parent != null && oneOfModelNames.contains(m.parent)) {
475+
m.parentIsOneOf = true;
476+
}
477+
}
478+
463479
for (ExtendedCodegenModel rootModel : allModels) {
464480
for (String curImport : rootModel.imports) {
465481
boolean isModelImport = false;
@@ -1545,6 +1561,8 @@ public class ExtendedCodegenModel extends CodegenModel {
15451561
public Set<CodegenProperty> oneOfPrimitives = new HashSet<>();
15461562
@Getter @Setter
15471563
public CodegenDiscriminator.MappedModel selfReferencingDiscriminatorMapping;
1564+
@Getter @Setter
1565+
public boolean parentIsOneOf; // true when this model's parent is a oneOf union type
15481566

15491567
public boolean isEntity; // Is a model containing an "id" property marked as isUniqueId
15501568
public String returnPassthrough;

modules/openapi-generator/src/main/resources/typescript-fetch/modelGenericInterfaces.mustache

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
/**
22
* {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}}
33
* @export
4+
{{^parentIsOneOf}}
45
* @interface {{classname}}
56
*/
67
export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
8+
{{/parentIsOneOf}}
9+
{{#parentIsOneOf}}
10+
*/
11+
export type {{classname}} = {{{parent}}} & {
12+
{{/parentIsOneOf}}
713
{{#additionalPropertiesType}}
814
[key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}};
915
{{/additionalPropertiesType}}
@@ -18,7 +24,7 @@ export interface {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
1824
*/
1925
{{#isReadOnly}}readonly {{/isReadOnly}}{{name}}{{^required}}?{{/required}}: {{{datatypeWithEnum}}}{{#isNullable}} | null{{/isNullable}};
2026
{{/vars}}
21-
}{{#hasEnums}}
27+
}{{#parentIsOneOf}};{{/parentIsOneOf}}{{#hasEnums}}
2228

2329
{{#vars}}
2430
{{#isEnum}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,43 @@ public void testRequestOptsNotInInterfaceWhenDisabled() throws IOException {
521521
assertThat(classSection).contains("async addPetRequestOpts(");
522522
}
523523

524+
/**
525+
* When a oneOf variant uses allOf to reference another oneOf (nested discriminated unions),
526+
* the child model must be generated as a type alias with intersection rather than an
527+
* interface with extends, because TypeScript does not allow interfaces to extend union types.
528+
*/
529+
@Test(description = "Verify nested oneOf generates type alias instead of interface extends")
530+
public void testNestedOneOfGeneratesTypeAliasForOneOfParent() throws IOException {
531+
File output = generate(
532+
Collections.emptyMap(),
533+
"src/test/resources/3_0/typescript-fetch/nested-oneOf.yaml"
534+
);
535+
536+
// OuterComposed's parent is Inner (a oneOf union type), so it must use
537+
// "type OuterComposed = Inner & { ... }" instead of "interface OuterComposed extends Inner"
538+
Path outerComposed = Paths.get(output + "/models/OuterComposed.ts");
539+
TestUtils.assertFileExists(outerComposed);
540+
TestUtils.assertFileContains(outerComposed, "export type OuterComposed = Inner & {");
541+
TestUtils.assertFileNotContains(outerComposed, "export interface OuterComposed extends Inner");
542+
543+
// Inner should still be a proper oneOf union type with discriminator dispatch
544+
Path inner = Paths.get(output + "/models/Inner.ts");
545+
TestUtils.assertFileExists(inner);
546+
TestUtils.assertFileContains(inner, "export type Inner = { innerDiscriminator: 'a' } & InnerA | { innerDiscriminator: 'b' } & InnerB");
547+
TestUtils.assertFileContains(inner, "switch (json['innerDiscriminator'])");
548+
549+
// Outer should dispatch on outerDiscriminator, including the composed variant
550+
Path outer = Paths.get(output + "/models/Outer.ts");
551+
TestUtils.assertFileExists(outer);
552+
TestUtils.assertFileContains(outer, "switch (json['outerDiscriminator'])");
553+
TestUtils.assertFileContains(outer, "case 'composed':");
554+
555+
// Regular models (not extending a oneOf parent) should still use interface
556+
Path outerPlain = Paths.get(output + "/models/OuterPlain.ts");
557+
TestUtils.assertFileExists(outerPlain);
558+
TestUtils.assertFileContains(outerPlain, "export interface OuterPlain {");
559+
}
560+
524561
private static File generate(
525562
Map<String, Object> properties
526563
) throws IOException {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
openapi: "3.0.3"
2+
info:
3+
title: Nested OneOf Test
4+
description: >
5+
Tests that a oneOf variant referencing another oneOf via allOf generates
6+
correct TypeScript types. The outer union (Outer) is discriminated by
7+
"outerDiscriminator"; one of its variants (OuterComposed) uses allOf to
8+
compose a fixed discriminator value with a $ref to an inner union (Inner)
9+
discriminated by "innerDiscriminator". A plain variant (OuterPlain) is
10+
included to verify normal interface generation is unaffected.
11+
version: "1.0"
12+
paths:
13+
/items:
14+
get:
15+
operationId: getItems
16+
responses:
17+
"200":
18+
description: OK
19+
content:
20+
application/json:
21+
schema:
22+
type: array
23+
items:
24+
$ref: "#/components/schemas/Outer"
25+
components:
26+
schemas:
27+
# ── Outer oneOf (discriminated by "outerDiscriminator") ──────────────
28+
Outer:
29+
oneOf:
30+
- $ref: "#/components/schemas/OuterPlain"
31+
- $ref: "#/components/schemas/OuterComposed"
32+
discriminator:
33+
propertyName: outerDiscriminator
34+
mapping:
35+
plain: "#/components/schemas/OuterPlain"
36+
composed: "#/components/schemas/OuterComposed"
37+
38+
OuterPlain:
39+
type: object
40+
required: [outerDiscriminator, plainValue]
41+
properties:
42+
outerDiscriminator:
43+
type: string
44+
plainValue:
45+
type: string
46+
47+
# Uses allOf to merge a fixed discriminator value with a nested oneOf ref
48+
OuterComposed:
49+
allOf:
50+
- type: object
51+
required: [outerDiscriminator]
52+
properties:
53+
outerDiscriminator:
54+
type: string
55+
- $ref: "#/components/schemas/Inner"
56+
57+
# ── Inner oneOf (discriminated by "innerDiscriminator") ─────────────
58+
Inner:
59+
oneOf:
60+
- $ref: "#/components/schemas/InnerA"
61+
- $ref: "#/components/schemas/InnerB"
62+
discriminator:
63+
propertyName: innerDiscriminator
64+
mapping:
65+
a: "#/components/schemas/InnerA"
66+
b: "#/components/schemas/InnerB"
67+
68+
InnerA:
69+
type: object
70+
required: [innerDiscriminator, fieldA]
71+
properties:
72+
innerDiscriminator:
73+
type: string
74+
fieldA:
75+
type: string
76+
77+
InnerB:
78+
type: object
79+
required: [innerDiscriminator, fieldB]
80+
properties:
81+
innerDiscriminator:
82+
type: string
83+
fieldB:
84+
type: integer

0 commit comments

Comments
 (0)