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:
- Emit
Item1 as an interface that extends the parent's shape so TypeScript treats the self-reference as nominal, or
- 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 oneOf — Base 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:
-
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.
-
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.
Description
When a discriminator parent schema has
oneOflisting variants that themselves useallOf: [{ $ref: <parent> }, ...extras]to inherit from the parent,@orval/core's type generator emits a TypeScript model that fails to compile withTS2456: Type alias 'X' circularly references itself.The cycle is between:
<Parent> = Item1 | Item2 | Item3(union built fromoneOf)Item1 = Omit<Parent, '<discriminatorKey>'> & { <discriminatorKey>: Item1Type, ... }(allOf inheritance viaOmit<>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)
Environment
master, post-fix(mock): preserve discriminator value when oneOf parent declares the same property #3429 and fix(mock): stop leaking sibling variants into allOf-inherited mocks #3431)tests/scripts/typecheck-generated.mjson the newdiscriminator-oneof-alloffixture (the fixture is currently excluded from typecheck precisely because of this issue).Expected behavior
gen/model/item1.ts,gen/model/discriminatorTest.tsetc. compile cleanly under--strict. Concretely, either:Item1as aninterfacethatextendsthe parent's shape so TypeScript treats the self-reference as nominal, orOmit<Parent, key>, breaking the alias cycle.Actual behavior
Generated
model/discriminatorTest.ts:Generated
model/item1.ts:Item1depends onDiscriminatorTest, which depends onItem1— TS2456.For comparison,
tests/specifications/recursive-discriminator-allof.yaml(which the repo already exercises) does compile because itsBaseschema has no top-leveloneOf—Basebecomes aninterfaceand the cross-reference goes through an indirection (Parent?: Derived1 | Derived2), which TypeScript can resolve.OpenAPI document (minimal)
The same fixture is now checked in at
tests/specifications/discriminator-oneof-allof.yaml(via PR #3431) and intentionally excluded fromtests/scripts/typecheck-generated.mjsuntil this issue is resolved.Additional context (root cause analysis)
The cycle originates from two independent decisions inside
@orval/core:Parent-as-alias. When a schema has top-level
oneOf,getCombineSchemaemits it as atypealias union rather than aninterface. Withrecursive-discriminator-allof'sBase(no top-leveloneOf),Basebecomes aninterface, which TS resolves recursively without complaint.Omit<Parent, discriminatorKey>in allOf-inheriting variants. When a variant isallOf: [{ $ref: <parent> }, ...]and the discriminator key is constrained per-variant (viaresolveDiscriminators's mapping injection inpackages/core/src/getters/discriminators.ts), the generator emitsItem1 = Omit<Parent, key> & { key: Item1Type, ... }. TheOmit<>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
interfacewithextendsWhen a variant inherits from a parent via
allOf, prefer emittinginterfacedeclarations have nominal-style identity and TypeScript can resolveinterface A extends Omit<B, 'k'>recursively whereB = A | …. Worth verifying against TS' constraints on extending mapped types, but historically this pattern works forOmit<>becauseOmit<>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), avoidOmit<Parent, key>and instead inline the parent's ownproperties(minus the discriminator key) directly into the variant: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
resolveDiscriminatorsalready 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 asItem1is no longer an alias depending onDiscriminatorTest.I'm happy to send a PR if a maintainer can confirm which direction is preferred.