Skip to content

Commit 46dd226

Browse files
committed
fix(typescript-fetch): use simple union when variants already declare discriminator property
When an OpenAPI spec declares the discriminator property on each variant schema (e.g., as a single-value enum), the generated TypeScript union type should use a simple union (ApiKey | Basic) instead of intersection wrappers ({ type: 'APIKEY' } & ApiKey). The intersection wrapper causes TypeScript to evaluate 'APIKEY' & ApiKeyTypeEnum as never (string literals and string enums are distinct nominal types), collapsing the entire union to never. This breaks all downstream code that references the union type. The fix detects when all discriminator variant models already have the discriminator as a required property and sets a vendor extension flag (x-variants-have-discriminator) on the discriminator. The templates then conditionally skip the intersection wrapper and Object.assign calls when this flag is set. Backward compatibility: when variants do NOT have the discriminator property (legacy specs), the intersection wrapper behavior is preserved.
1 parent cca5dda commit 46dd226

5 files changed

Lines changed: 108 additions & 2 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,40 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
475475
}
476476
}
477477
}
478+
479+
// Detect if discriminator variants already declare the discriminator property.
480+
// When they do, the union type should use simple unions (e.g., ApiKey | Basic)
481+
// instead of intersection wrappers (e.g., { type: 'APIKEY' } & ApiKey), because
482+
// the intersection of a string literal with a string enum evaluates to `never`
483+
// in TypeScript when stringEnums is enabled.
484+
for (ExtendedCodegenModel rootModel : allModels) {
485+
CodegenDiscriminator discriminator = rootModel.discriminator;
486+
if (discriminator == null || discriminator.getMappedModels() == null
487+
|| discriminator.getMappedModels().isEmpty()) {
488+
continue;
489+
}
490+
String discPropBaseName = discriminator.getPropertyBaseName();
491+
boolean allVariantsHaveDiscriminator = true;
492+
for (CodegenDiscriminator.MappedModel mm : discriminator.getMappedModels()) {
493+
boolean variantHasProp = false;
494+
for (ExtendedCodegenModel model : allModels) {
495+
if (model.classname.equals(mm.getModelName())) {
496+
variantHasProp = model.vars.stream()
497+
.anyMatch(v -> v.baseName.equals(discPropBaseName));
498+
break;
499+
}
500+
}
501+
if (!variantHasProp) {
502+
allVariantsHaveDiscriminator = false;
503+
break;
504+
}
505+
}
506+
if (allVariantsHaveDiscriminator) {
507+
discriminator.getVendorExtensions()
508+
.put("x-variants-have-discriminator", true);
509+
}
510+
}
511+
478512
return result;
479513
}
480514

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole
3434
switch (json['{{discriminator.propertyBaseName}}']) {
3535
{{#discriminator.mappedModels}}
3636
case '{{mappingName}}':
37+
{{#discriminator.vendorExtensions.x-variants-have-discriminator}}
38+
return {{modelName}}FromJSONTyped(json, true);
39+
{{/discriminator.vendorExtensions.x-variants-have-discriminator}}
40+
{{^discriminator.vendorExtensions.x-variants-have-discriminator}}
3741
return Object.assign({}, {{modelName}}FromJSONTyped(json, true), { {{discriminator.propertyName}}: '{{mappingName}}' } as const);
42+
{{/discriminator.vendorExtensions.x-variants-have-discriminator}}
3843
{{/discriminator.mappedModels}}
3944
default:
4045
return json;
@@ -151,7 +156,12 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis
151156
switch (value['{{discriminator.propertyName}}']) {
152157
{{#discriminator.mappedModels}}
153158
case '{{mappingName}}':
159+
{{#discriminator.vendorExtensions.x-variants-have-discriminator}}
160+
return {{modelName}}ToJSON(value);
161+
{{/discriminator.vendorExtensions.x-variants-have-discriminator}}
162+
{{^discriminator.vendorExtensions.x-variants-have-discriminator}}
154163
return Object.assign({}, {{modelName}}ToJSON(value), { {{discriminator.propertyName}}: '{{mappingName}}' } as const);
164+
{{/discriminator.vendorExtensions.x-variants-have-discriminator}}
155165
{{/discriminator.mappedModels}}
156166
default:
157167
return value;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
* {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}}
44
* @export
55
*/
6-
export type {{classname}} = {{#discriminator}}{{#mappedModels}}{ {{discriminator.propertyName}}: '{{mappingName}}' } & {{modelName}}{{^-last}} | {{/-last}}{{/mappedModels}}{{/discriminator}}{{^discriminator}}{{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}}{{/discriminator}};
6+
export type {{classname}} = {{#discriminator}}{{#vendorExtensions.x-variants-have-discriminator}}{{#mappedModels}}{{modelName}}{{^-last}} | {{/-last}}{{/mappedModels}}{{/vendorExtensions.x-variants-have-discriminator}}{{^vendorExtensions.x-variants-have-discriminator}}{{#mappedModels}}{ {{discriminator.propertyName}}: '{{mappingName}}' } & {{modelName}}{{^-last}} | {{/-last}}{{/mappedModels}}{{/vendorExtensions.x-variants-have-discriminator}}{{/discriminator}}{{^discriminator}}{{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}}{{/discriminator}};

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,14 @@ public void testOneOfModelsDoNotImportPrimitiveTypes() throws IOException {
403403
TestUtils.assertFileExists(testDiscriminatorResponse);
404404
TestUtils.assertFileContains(testDiscriminatorResponse, "import type { OptionOne } from './OptionOne'");
405405
TestUtils.assertFileContains(testDiscriminatorResponse, "import type { OptionTwo } from './OptionTwo'");
406-
TestUtils.assertFileContains(testDiscriminatorResponse, "export type TestDiscriminatorResponse = { discriminatorField: 'optionOne' } & OptionOne | { discriminatorField: 'optionTwo' } & OptionTwo");
406+
// When variants already declare the discriminator property (OptionOne/OptionTwo have
407+
// discriminatorField as a required single-value enum), the union should use simple types
408+
// instead of intersection wrappers to avoid TypeScript `never` type issues with string enums
409+
TestUtils.assertFileContains(testDiscriminatorResponse, "export type TestDiscriminatorResponse = OptionOne | OptionTwo");
410+
TestUtils.assertFileNotContains(testDiscriminatorResponse, "{ discriminatorField: 'optionOne' } & OptionOne");
411+
// FromJSON should delegate directly without Object.assign wrapper
412+
TestUtils.assertFileContains(testDiscriminatorResponse, "return OptionOneFromJSONTyped(json, true)");
413+
TestUtils.assertFileNotContains(testDiscriminatorResponse, "Object.assign");
407414
}
408415

409416
/**
@@ -444,6 +451,21 @@ public void testOneOfModelsImportNonPrimitiveTypes() throws IOException {
444451
TestUtils.assertFileContains(testResponse, "import type { OptionThree } from './OptionThree'");
445452
}
446453

454+
@Test(description = "Verify discriminator uses intersection wrappers when variants do NOT have discriminator property")
455+
public void testDiscriminatorWithoutPropertyOnVariantsUsesIntersectionWrapper() throws IOException {
456+
File output = generate(
457+
Collections.emptyMap(),
458+
"src/test/resources/3_0/typescript-fetch/discriminator-without-property.yaml"
459+
);
460+
461+
Path shapePath = Paths.get(output + "/models/Shape.ts");
462+
TestUtils.assertFileExists(shapePath);
463+
// When variants don't have the discriminator property, intersection wrappers should be used
464+
TestUtils.assertFileContains(shapePath, "{ shapeType: 'circle' } & Circle");
465+
TestUtils.assertFileContains(shapePath, "{ shapeType: 'square' } & Square");
466+
TestUtils.assertFileContains(shapePath, "Object.assign");
467+
}
468+
447469
@Test(description = "Verify validationAttributes works with withoutRuntimeChecks=true")
448470
public void testValidationAttributesWithWithoutRuntimeChecks() throws IOException {
449471
Map<String, Object> properties = new HashMap<>();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
openapi: 3.0.0
2+
info:
3+
version: 1.0.0
4+
title: testing discriminator when variants do NOT have discriminator property
5+
paths:
6+
/test:
7+
get:
8+
operationId: test
9+
responses:
10+
200:
11+
description: OK
12+
content:
13+
application/json:
14+
schema:
15+
$ref: '#/components/schemas/Shape'
16+
components:
17+
schemas:
18+
Shape:
19+
discriminator:
20+
propertyName: shapeType
21+
mapping:
22+
circle: "#/components/schemas/Circle"
23+
square: "#/components/schemas/Square"
24+
oneOf:
25+
- $ref: "#/components/schemas/Circle"
26+
- $ref: "#/components/schemas/Square"
27+
Circle:
28+
type: object
29+
properties:
30+
radius:
31+
type: number
32+
required:
33+
- radius
34+
Square:
35+
type: object
36+
properties:
37+
side:
38+
type: number
39+
required:
40+
- side

0 commit comments

Comments
 (0)