Skip to content

Discriminator parent with allOf-inheriting variants emits circular type aliases (Omit<Parent, key> & {...}Parent = ItemN | ...) #3432

Description

@wadakatu

Description

When a discriminator parent schema has oneOf listing variants that themselves use allOf: [{ $ref: <parent> }, ...extras] to inherit from the parent, @orval/core's type generator emits a TypeScript model that fails to compile with TS2456: Type alias 'X' circularly references itself.

The cycle is between:

  • <Parent> = Item1 | Item2 | Item3 (union built from oneOf)
  • Item1 = Omit<Parent, '<discriminatorKey>'> & { <discriminatorKey>: Item1Type, ... } (allOf inheritance via Omit<> indirection)

TypeScript can express recursive interfaces but cannot resolve recursive type aliases through Omit<>, so all three variants and the parent fail to compile together.

This is the exact shape used in the reproduction for #2155, and surfaced while adding the regression fixture for that bug (see PR #3431). The mock-side defects are tracked separately in #3429 / #3431; this issue tracks only the type-generation half.

Output client

other (not client-specific — fails during type generation, observable across every client).

Configuration (orval.config)

module.exports = {
  api: {
    input: { target: './api.yaml' },
    output: { target: './gen/endpoints.ts', schemas: './gen/model', mock: true },
  },
};

Environment

Expected behavior

gen/model/item1.ts, gen/model/discriminatorTest.ts etc. compile cleanly under --strict. Concretely, either:

  1. Emit Item1 as an interface that extends the parent's shape so TypeScript treats the self-reference as nominal, or
  2. Inline the parent's non-discriminator properties into each variant instead of going through Omit<Parent, key>, breaking the alias cycle.

Actual behavior

generated/mock/discriminator-oneof-allof/model/discriminatorTest.ts(12,13): error TS2456: Type alias 'DiscriminatorTest' circularly references itself.
generated/mock/discriminator-oneof-allof/model/item1.ts(10,13): error TS2456: Type alias 'Item1' circularly references itself.
generated/mock/discriminator-oneof-allof/model/item2.ts(10,13): error TS2456: Type alias 'Item2' circularly references itself.
generated/mock/discriminator-oneof-allof/model/item3.ts(10,13): error TS2456: Type alias 'Item3' circularly references itself.

Generated model/discriminatorTest.ts:

import type { DiscriminatorTestType } from './discriminatorTestType';
import type { Item1 } from './item1';
import type { Item2 } from './item2';
import type { Item3 } from './item3';

export type DiscriminatorTest =
  | (Item1 & { type: DiscriminatorTestType })
  | (Item2 & { type: DiscriminatorTestType })
  | (Item3 & { type: DiscriminatorTestType });

Generated model/item1.ts:

import type { DiscriminatorTest } from './discriminatorTest';
import type { Item1Type } from './item1Type';

export type Item1 = Omit<DiscriminatorTest, 'type'> & {
  type: Item1Type;
  property1?: string;
};

Item1 depends on DiscriminatorTest, which depends on Item1 — TS2456.

For comparison, tests/specifications/recursive-discriminator-allof.yaml (which the repo already exercises) does compile because its Base schema has no top-level oneOfBase becomes an interface and the cross-reference goes through an indirection (Parent?: Derived1 | Derived2), which TypeScript can resolve.

OpenAPI document (minimal)

openapi: '3.0.2'
info:
  title: Discriminator with oneOf union and allOf-inherited variants
  version: '1.0'
components:
  schemas:
    DiscriminatorTest:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [item1, item2, item3]
      discriminator:
        propertyName: type
        mapping:
          item1: '#/components/schemas/Item1'
          item2: '#/components/schemas/Item2'
          item3: '#/components/schemas/Item3'
      oneOf:
        - $ref: '#/components/schemas/Item1'
        - $ref: '#/components/schemas/Item2'
        - $ref: '#/components/schemas/Item3'
    Item1:
      allOf:
        - $ref: '#/components/schemas/DiscriminatorTest'
        - type: object
          properties:
            property1: { type: string }
    Item2:
      allOf:
        - $ref: '#/components/schemas/DiscriminatorTest'
        - type: object
          properties:
            property2: { type: string }
    Item3:
      allOf:
        - $ref: '#/components/schemas/DiscriminatorTest'
        - type: object
          properties:
            property3: { type: string }
paths:
  /test:
    get:
      operationId: getTest
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DiscriminatorTest'

The same fixture is now checked in at tests/specifications/discriminator-oneof-allof.yaml (via PR #3431) and intentionally excluded from tests/scripts/typecheck-generated.mjs until this issue is resolved.

Additional context (root cause analysis)

The cycle originates from two independent decisions inside @orval/core:

  1. Parent-as-alias. When a schema has top-level oneOf, getCombineSchema emits it as a type alias union rather than an interface. With recursive-discriminator-allof's Base (no top-level oneOf), Base becomes an interface, which TS resolves recursively without complaint.

  2. Omit<Parent, discriminatorKey> in allOf-inheriting variants. When a variant is allOf: [{ $ref: <parent> }, ...] and the discriminator key is constrained per-variant (via resolveDiscriminators's mapping injection in packages/core/src/getters/discriminators.ts), the generator emits Item1 = Omit<Parent, key> & { key: Item1Type, ... }. The Omit<> is what couples the variant's alias back to the parent's alias.

Either of the following would break the cycle:

Proposed fix A — emit variants as interface with extends

When a variant inherits from a parent via allOf, prefer emitting

export interface Item1 extends Omit<DiscriminatorTest, 'type'> {
  type: Item1Type;
  property1?: string;
}

interface declarations have nominal-style identity and TypeScript can resolve interface A extends Omit<B, 'k'> recursively where B = A | …. Worth verifying against TS' constraints on extending mapped types, but historically this pattern works for Omit<> because Omit<> resolves to an object literal type.

Proposed fix B — inline parent's non-discriminator properties

When a variant inherits from a parent that itself has oneOf (i.e. the parent's TS form is an alias union), avoid Omit<Parent, key> and instead inline the parent's own properties (minus the discriminator key) directly into the variant:

export interface Item1 {
  // (parent's non-discriminator properties go here)
  type: Item1Type;
  property1?: string;
}

This avoids depending on the parent's alias entirely. Behaviourally equivalent at the JSON level — the variant carries all of the parent's required fields anyway because resolveDiscriminators already encodes the constrained discriminator on the variant, and the allOf spec requires the variant to satisfy the parent.

Either fix should also keep DiscriminatorTest's emitted union working — that side is fine as long as Item1 is no longer an alias depending on DiscriminatorTest.

I'm happy to send a PR if a maintainer can confirm which direction is preferred.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions