Skip to content

fix(typescript-fetch): use simple union when variants already declare discriminator property#23178

Open
guizmaii wants to merge 3 commits intoOpenAPITools:masterfrom
guizmaii:fix/typescript-fetch-discriminator-intersection-never
Open

fix(typescript-fetch): use simple union when variants already declare discriminator property#23178
guizmaii wants to merge 3 commits intoOpenAPITools:masterfrom
guizmaii:fix/typescript-fetch-discriminator-intersection-never

Conversation

@guizmaii
Copy link
Contributor

@guizmaii guizmaii commented Mar 9, 2026

Problem

When an OpenAPI spec declares the discriminator property on each variant schema (as a required single-value enum -- which is the correct behavior per the OpenAPI 3.x spec), the typescript-fetch generator produces union types with intersection wrappers that break TypeScript compilation.

OpenAPI spec:

components:
  schemas:
    Authentication:
      oneOf:
        - $ref: '#/components/schemas/ApiKey'
        - $ref: '#/components/schemas/Basic'
      discriminator:
        propertyName: type
        mapping:
          APIKEY: '#/components/schemas/ApiKey'
          BASIC: '#/components/schemas/Basic'

    ApiKey:
      type: object
      required: [type, apiKey]
      properties:
        type:
          type: string
          enum: [APIKEY]       # discriminator as single-value enum
        apiKey:
          type: string

    Basic:
      type: object
      required: [type, username, password]
      properties:
        type:
          type: string
          enum: [BASIC]        # discriminator as single-value enum
        username:
          type: string
        password:
          type: string

Generated code (broken):

// models/ApiKey.ts -- generated enum for the discriminator property
export const ApiKeyTypeEnum = { Apikey: 'APIKEY' } as const;
export type ApiKeyTypeEnum = typeof ApiKeyTypeEnum[keyof typeof ApiKeyTypeEnum];

export interface ApiKey {
    type: ApiKeyTypeEnum;   // ApiKeyTypeEnum, not a string literal
    apiKey: string;
}

// models/Authentication.ts -- intersection wrapper duplicates the discriminator
export type Authentication =
  | { type: 'APIKEY' } & ApiKey
  | { type: 'BASIC' } & Basic;

The intersection { type: 'APIKEY' } & ApiKey requires type to be both the string literal 'APIKEY' and ApiKeyTypeEnum simultaneously. TypeScript treats string literals and const enum members as distinct nominal types, so 'APIKEY' & ApiKeyTypeEnum evaluates to never. This collapses the discriminant property to never, making the entire branch of the union uninhabitable:

// What TypeScript actually sees after type reduction:
type Authentication =
  | { type: never; apiKey: string }     // can never be constructed
  | { type: never; username: string; password: string };

// Downstream code breaks:
const auth: Authentication = {
  type: 'APIKEY',  // Error: Type '"APIKEY"' is not assignable to type 'never'
  apiKey: 'secret',
};

This happens whenever variant schemas correctly include the discriminator as a required property -- which is the valid approach per the OpenAPI 3.x spec.

Solution

In TypeScriptFetchClientCodegen.postProcessAllModels(), detect when all discriminator variant models already declare the discriminator as a required property. When they do, set a vendor extension flag (x-variants-have-discriminator) on the discriminator.

The Mustache templates (modelOneOfInterfaces.mustache, modelOneOf.mustache) then conditionally:

  • Emit simple unions (ApiKey | Basic) instead of intersection wrappers ({ type: 'APIKEY' } & ApiKey)
  • Delegate directly to variant FromJSONTyped/ToJSON instead of using Object.assign with literal wrappers

Generated code (fixed):

// Simple union -- no intersection, no type conflict
export type Authentication = ApiKey | Basic;

// FromJSON uses direct delegation
export function AuthenticationFromJSONTyped(json: any, ignoreDiscriminator: boolean): Authentication {
    if (json == null) { return json; }
    switch (json['type']) {
        case 'APIKEY':
            return ApiKeyFromJSONTyped(json, true);
        case 'BASIC':
            return BasicFromJSONTyped(json, true);
        default:
            return json;
    }
}

Backward compatibility: When variants do NOT have the discriminator property (legacy/non-compliant specs), the intersection wrapper behavior is preserved unchanged.

Test plan

  • Updated existing testOneOfModelsDoNotImportPrimitiveTypes test -- the existing oneOf.yaml spec already has variants with discriminator properties (OptionOne.discriminatorField), so the test now correctly asserts simple unions
  • Added testDiscriminatorWithoutPropertyOnVariantsUsesIntersectionWrapper test with a new spec (discriminator-without-property.yaml) to verify backward compatibility
  • All 23 TypeScriptFetchClientCodegenTest tests pass

… 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.
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptFetchClientCodegen.java:497">
P2: Discriminator-variant detection ignores requiredness, so optional discriminator fields can wrongly enable simple-union generation and altered JSON conversion behavior.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

guizmaii added 2 commits March 9, 2026 15:12
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.
Update TestDiscriminatorResponse sample output to use simple
unions and direct delegation since the spec's variants already
declare the discriminator property.
@guizmaii guizmaii force-pushed the fix/typescript-fetch-discriminator-intersection-never branch from e220f93 to 3035ab0 Compare March 9, 2026 05:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant