From 46dd226949a27fd90bdd724bfbee5e294d1e1ed7 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Mon, 9 Mar 2026 14:52:57 +1100 Subject: [PATCH 1/3] 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. --- .../TypeScriptFetchClientCodegen.java | 34 ++++++++++++++++ .../typescript-fetch/modelOneOf.mustache | 10 +++++ .../modelOneOfInterfaces.mustache | 2 +- .../TypeScriptFetchClientCodegenTest.java | 24 ++++++++++- .../discriminator-without-property.yaml | 40 +++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/typescript-fetch/discriminator-without-property.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 482c286927c7..e4d368ab86c8 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 @@ -475,6 +475,40 @@ public Map postProcessAllModels(Map objs) } } } + + // Detect if discriminator variants already declare the discriminator property. + // When they do, the union type should use simple unions (e.g., ApiKey | Basic) + // instead of intersection wrappers (e.g., { type: 'APIKEY' } & ApiKey), because + // the intersection of a string literal with a string enum evaluates to `never` + // in TypeScript when stringEnums is enabled. + for (ExtendedCodegenModel rootModel : allModels) { + CodegenDiscriminator discriminator = rootModel.discriminator; + if (discriminator == null || discriminator.getMappedModels() == null + || discriminator.getMappedModels().isEmpty()) { + continue; + } + String discPropBaseName = discriminator.getPropertyBaseName(); + boolean allVariantsHaveDiscriminator = true; + for (CodegenDiscriminator.MappedModel mm : discriminator.getMappedModels()) { + boolean variantHasProp = false; + for (ExtendedCodegenModel model : allModels) { + if (model.classname.equals(mm.getModelName())) { + variantHasProp = model.vars.stream() + .anyMatch(v -> v.baseName.equals(discPropBaseName)); + break; + } + } + if (!variantHasProp) { + allVariantsHaveDiscriminator = false; + break; + } + } + if (allVariantsHaveDiscriminator) { + discriminator.getVendorExtensions() + .put("x-variants-have-discriminator", true); + } + } + return result; } diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOf.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOf.mustache index 46352ea71acd..653ed87cf716 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOf.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOf.mustache @@ -34,7 +34,12 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole switch (json['{{discriminator.propertyBaseName}}']) { {{#discriminator.mappedModels}} case '{{mappingName}}': +{{#discriminator.vendorExtensions.x-variants-have-discriminator}} + return {{modelName}}FromJSONTyped(json, true); +{{/discriminator.vendorExtensions.x-variants-have-discriminator}} +{{^discriminator.vendorExtensions.x-variants-have-discriminator}} return Object.assign({}, {{modelName}}FromJSONTyped(json, true), { {{discriminator.propertyName}}: '{{mappingName}}' } as const); +{{/discriminator.vendorExtensions.x-variants-have-discriminator}} {{/discriminator.mappedModels}} default: return json; @@ -151,7 +156,12 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis switch (value['{{discriminator.propertyName}}']) { {{#discriminator.mappedModels}} case '{{mappingName}}': +{{#discriminator.vendorExtensions.x-variants-have-discriminator}} + return {{modelName}}ToJSON(value); +{{/discriminator.vendorExtensions.x-variants-have-discriminator}} +{{^discriminator.vendorExtensions.x-variants-have-discriminator}} return Object.assign({}, {{modelName}}ToJSON(value), { {{discriminator.propertyName}}: '{{mappingName}}' } as const); +{{/discriminator.vendorExtensions.x-variants-have-discriminator}} {{/discriminator.mappedModels}} default: return value; diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOfInterfaces.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOfInterfaces.mustache index ee4d1fccd8eb..9297732764c0 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOfInterfaces.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/modelOneOfInterfaces.mustache @@ -3,4 +3,4 @@ * {{#lambda.indented_star_1}}{{{unescapedDescription}}}{{/lambda.indented_star_1}} * @export */ -export type {{classname}} = {{#discriminator}}{{#mappedModels}}{ {{discriminator.propertyName}}: '{{mappingName}}' } & {{modelName}}{{^-last}} | {{/-last}}{{/mappedModels}}{{/discriminator}}{{^discriminator}}{{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}}{{/discriminator}}; \ No newline at end of file +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}}; \ No newline at end of file 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 da8324317640..449b6d898ee2 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 @@ -403,7 +403,14 @@ public void testOneOfModelsDoNotImportPrimitiveTypes() throws IOException { TestUtils.assertFileExists(testDiscriminatorResponse); TestUtils.assertFileContains(testDiscriminatorResponse, "import type { OptionOne } from './OptionOne'"); TestUtils.assertFileContains(testDiscriminatorResponse, "import type { OptionTwo } from './OptionTwo'"); - TestUtils.assertFileContains(testDiscriminatorResponse, "export type TestDiscriminatorResponse = { discriminatorField: 'optionOne' } & OptionOne | { discriminatorField: 'optionTwo' } & OptionTwo"); + // When variants already declare the discriminator property (OptionOne/OptionTwo have + // discriminatorField as a required single-value enum), the union should use simple types + // instead of intersection wrappers to avoid TypeScript `never` type issues with string enums + TestUtils.assertFileContains(testDiscriminatorResponse, "export type TestDiscriminatorResponse = OptionOne | OptionTwo"); + TestUtils.assertFileNotContains(testDiscriminatorResponse, "{ discriminatorField: 'optionOne' } & OptionOne"); + // FromJSON should delegate directly without Object.assign wrapper + TestUtils.assertFileContains(testDiscriminatorResponse, "return OptionOneFromJSONTyped(json, true)"); + TestUtils.assertFileNotContains(testDiscriminatorResponse, "Object.assign"); } /** @@ -444,6 +451,21 @@ public void testOneOfModelsImportNonPrimitiveTypes() throws IOException { TestUtils.assertFileContains(testResponse, "import type { OptionThree } from './OptionThree'"); } + @Test(description = "Verify discriminator uses intersection wrappers when variants do NOT have discriminator property") + public void testDiscriminatorWithoutPropertyOnVariantsUsesIntersectionWrapper() throws IOException { + File output = generate( + Collections.emptyMap(), + "src/test/resources/3_0/typescript-fetch/discriminator-without-property.yaml" + ); + + Path shapePath = Paths.get(output + "/models/Shape.ts"); + TestUtils.assertFileExists(shapePath); + // When variants don't have the discriminator property, intersection wrappers should be used + TestUtils.assertFileContains(shapePath, "{ shapeType: 'circle' } & Circle"); + TestUtils.assertFileContains(shapePath, "{ shapeType: 'square' } & Square"); + TestUtils.assertFileContains(shapePath, "Object.assign"); + } + @Test(description = "Verify validationAttributes works with withoutRuntimeChecks=true") public void testValidationAttributesWithWithoutRuntimeChecks() throws IOException { Map properties = new HashMap<>(); diff --git a/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/discriminator-without-property.yaml b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/discriminator-without-property.yaml new file mode 100644 index 000000000000..3211f02905e1 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/discriminator-without-property.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: testing discriminator when variants do NOT have discriminator property +paths: + /test: + get: + operationId: test + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Shape' +components: + schemas: + Shape: + discriminator: + propertyName: shapeType + mapping: + circle: "#/components/schemas/Circle" + square: "#/components/schemas/Square" + oneOf: + - $ref: "#/components/schemas/Circle" + - $ref: "#/components/schemas/Square" + Circle: + type: object + properties: + radius: + type: number + required: + - radius + Square: + type: object + properties: + side: + type: number + required: + - side From bda0801c5e99c180871b57b0e69c53214c1b186c Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Mon, 9 Mar 2026 15:12:39 +1100 Subject: [PATCH 2/3] Improve readability of discriminator detection logic Extract predicates into named boolean variables to make the discriminator variant detection code easier to follow. Replace the nested loop with a stream-based lookup for finding variant models. --- .../TypeScriptFetchClientCodegen.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 e4d368ab86c8..29d982c0032a 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 @@ -483,22 +483,23 @@ public Map postProcessAllModels(Map objs) // in TypeScript when stringEnums is enabled. for (ExtendedCodegenModel rootModel : allModels) { CodegenDiscriminator discriminator = rootModel.discriminator; - if (discriminator == null || discriminator.getMappedModels() == null - || discriminator.getMappedModels().isEmpty()) { + boolean hasDiscriminator = discriminator != null; + boolean hasMappedModels = hasDiscriminator + && discriminator.getMappedModels() != null + && !discriminator.getMappedModels().isEmpty(); + if (!hasMappedModels) { continue; } String discPropBaseName = discriminator.getPropertyBaseName(); boolean allVariantsHaveDiscriminator = true; for (CodegenDiscriminator.MappedModel mm : discriminator.getMappedModels()) { - boolean variantHasProp = false; - for (ExtendedCodegenModel model : allModels) { - if (model.classname.equals(mm.getModelName())) { - variantHasProp = model.vars.stream() - .anyMatch(v -> v.baseName.equals(discPropBaseName)); - break; - } - } - if (!variantHasProp) { + boolean variantDeclaresDiscriminatorProperty = allModels.stream() + .filter(model -> model.classname.equals(mm.getModelName())) + .findFirst() + .map(model -> model.vars.stream() + .anyMatch(v -> v.baseName.equals(discPropBaseName))) + .orElse(false); + if (!variantDeclaresDiscriminatorProperty) { allVariantsHaveDiscriminator = false; break; } From 3035ab013d30c27210d7004de9c18e864abac17a Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Mon, 9 Mar 2026 15:35:22 +1100 Subject: [PATCH 3/3] Regenerate oneOf sample to reflect template changes Update TestDiscriminatorResponse sample output to use simple unions and direct delegation since the spec's variants already declare the discriminator property. --- .../builds/oneOf/models/TestDiscriminatorResponse.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/samples/client/petstore/typescript-fetch/builds/oneOf/models/TestDiscriminatorResponse.ts b/samples/client/petstore/typescript-fetch/builds/oneOf/models/TestDiscriminatorResponse.ts index 0596cafad7e1..ac5fabce7447 100644 --- a/samples/client/petstore/typescript-fetch/builds/oneOf/models/TestDiscriminatorResponse.ts +++ b/samples/client/petstore/typescript-fetch/builds/oneOf/models/TestDiscriminatorResponse.ts @@ -32,7 +32,7 @@ import { * * @export */ -export type TestDiscriminatorResponse = { discriminatorField: 'optionOne' } & OptionOne | { discriminatorField: 'optionTwo' } & OptionTwo; +export type TestDiscriminatorResponse = OptionOne | OptionTwo; export function TestDiscriminatorResponseFromJSON(json: any): TestDiscriminatorResponse { return TestDiscriminatorResponseFromJSONTyped(json, false); @@ -44,9 +44,9 @@ export function TestDiscriminatorResponseFromJSONTyped(json: any, ignoreDiscrimi } switch (json['discriminatorField']) { case 'optionOne': - return Object.assign({}, OptionOneFromJSONTyped(json, true), { discriminatorField: 'optionOne' } as const); + return OptionOneFromJSONTyped(json, true); case 'optionTwo': - return Object.assign({}, OptionTwoFromJSONTyped(json, true), { discriminatorField: 'optionTwo' } as const); + return OptionTwoFromJSONTyped(json, true); default: return json; } @@ -62,9 +62,9 @@ export function TestDiscriminatorResponseToJSONTyped(value?: TestDiscriminatorRe } switch (value['discriminatorField']) { case 'optionOne': - return Object.assign({}, OptionOneToJSON(value), { discriminatorField: 'optionOne' } as const); + return OptionOneToJSON(value); case 'optionTwo': - return Object.assign({}, OptionTwoToJSON(value), { discriminatorField: 'optionTwo' } as const); + return OptionTwoToJSON(value); default: return value; }