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..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 @@ -475,6 +475,41 @@ 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; + 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 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; + } + } + 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 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; }