Skip to content

fix(core): break circular type alias for allOf-inheriting discriminator variants - #3435

Merged
melloware merged 3 commits into
orval-labs:masterfrom
wadakatu:fix/3432-discriminator-circular-type-alias
May 25, 2026
Merged

fix(core): break circular type alias for allOf-inheriting discriminator variants#3435
melloware merged 3 commits into
orval-labs:masterfrom
wadakatu:fix/3432-discriminator-circular-type-alias

Conversation

@wadakatu

@wadakatu wadakatu commented May 25, 2026

Copy link
Copy Markdown
Contributor

Status

  • I have followed every step in the contributing guide
  • The pull request title follows the conventional commits convention
  • The pull request updates the docs if applicable — N/A (internal type-generation fix)
  • The pull request includes new tests if applicable

Description

Closes #3432.

When a discriminator parent has top-level oneOf listing variants that inherit via allOf: [{ $ref: <parent> }, ...], the type generator emits:

// model/discriminatorTest.ts
export type DiscriminatorTest =
  | (Item1 & { type: DiscriminatorTestType })
  | (Item2 & { type: DiscriminatorTestType })
  | (Item3 & { type: DiscriminatorTestType });

// model/item1.ts ← circular: depends on DiscriminatorTest which depends on Item1
export type Item1 = Omit<DiscriminatorTest, 'type'> & {
  type: Item1Type;
  property1?: string;
};

which fails to compile with TS2456: Type alias 'X' circularly references itself. The discriminator-oneof-allof fixture (added in #3431 as part of the #2155 mock fix) was excluded from tests/scripts/typecheck-generated.mjs precisely to defer this.

Why the suggestions in the issue body need adjusting

I drafted both proposed directions in #3432 and verified them in a TypeScript sandbox:

  • Fix A — interface Variant extends Omit<Parent, key> does not compile. TypeScript errors TS2310: Type 'Variant' recursively references itself as a base type. interface extends Omit<UnionType, K> can't resolve when Parent is a union containing Variant.
  • Fix B — inline the parent's non-discriminator properties into each variant compiles cleanly. This PR implements Fix B.

Approach

Rewrite each variant's $ref-back-to-the-parent inside resolveDiscriminators so the variant never depends on the parent's alias. For every parent that has top-level oneOf + a discriminator.mapping, iterate the mapping targets and walk each variant's allOf. When an entry is a $ref whose originalName matches the parent:

  • If the parent has properties beyond the discriminator key → replace the $ref with an inline { type: 'object', properties, required? } carrying those properties (minus the discriminator key).
  • If the parent contributes nothing beyond the discriminator key (the fixture's case) → drop the entry entirely.

Downstream normalizeAllOfSchema then merges what remains back into the variant's own object, and shouldCreateInterface lets the variant emit as a non-circular shape.

Scope check — other fixtures with discriminator + oneOf + allOf

I grepped the spec directory for the trigger pattern and confirmed each is untouched:

Fixture Why it's unaffected
recursive-discriminator-allof.yaml Base has no top-level oneOf → gate fails
lowercase-discriminator.yaml Variants inherit from Base, not from the discriminator parent resp → ref-name check fails
one-of-nested.yaml Variants are standalone (no allOf); Example2.expiry: allOf:[$ref: PointInFuture] is a field, not a mapping target
polymorphic.yaml ParentType has no top-level oneOf → gate fails

Empirically confirmed by regenerating all 15 clients and observing that only the mock/discriminator-oneof-allof fixture's three variant item*.ts files changed shape. discriminatorTest.ts, endpoints.ts, and every other fixture are byte-identical.

Snapshot diff for the affected fixture

Verification

  • New focused tests in packages/core/src/getters/discriminators.test.ts lock in three behaviours: the rewrite drops the $ref when the parent has no inheritable props (Discriminator parent with allOf-inheriting variants emits circular type aliases (Omit<Parent, key> & {...}Parent = ItemN | ...) #3432's exact shape), the rewrite inlines parent props when present, and the existing non-oneOf-parent case is left untouched.
  • The discriminator-oneof-allof fixture is removed from the typecheck-generated.mjs exclusion list; mock now passes typechecking alongside the other 14 clients.
  • pnpm test, pnpm lint, pnpm typecheck, pnpm test:snapshots, and the per-client node ./scripts/typecheck-generated.mjs all pass locally.

Summary by CodeRabbit

  • Bug Fixes

    • Resolved circular TypeScript type-alias issues for discriminated unions assembled from oneOf/allOf inheritance.
    • Ensured inheritable parent properties are properly inlined per variant without sharing references.
  • New Features

    • Added generated mock endpoints and typed response builders for an Animal discriminator scenario.
    • Added a new OpenAPI test fixture and generation config for the inherited-discriminator case.
  • Tests

    • Added comprehensive tests covering discriminator normalization and rewrite behaviors.

Review Change Stack

Copilot AI review requested due to automatic review settings May 25, 2026 00:52
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Rewrites variant allOf entries that reference a discriminator parent with top-level oneOf by inlining the parent's non-discriminator properties into each variant; adds tests, updates generated snapshots and mocks, and includes the fixture in typechecking to validate the TS2456 circular-alias fix.

Changes

Circular type alias resolution for discriminated oneOf unions

Layer / File(s) Summary
resolveDiscriminators normalization pass for oneOf parents
packages/core/src/getters/discriminators.ts
Added a second pass in resolveDiscriminators that detects discriminator parents with oneOf and discriminator.mapping, collects inheritable parent properties/required (excluding the discriminator key), replaces variant allOf $ref entries pointing to the parent with inline shallow clones of the parent (removing oneOf, discriminator, allOf, anyOf), and deletes empty allOf arrays.
Unit tests for allOf normalization
packages/core/src/getters/discriminators.test.ts
Added five Vitest tests validating: parent $ref removal for oneOf parents; inlining of non-discriminator parent properties and required; preservation of parent object-level constraints while omitting composition keys; per-variant cloning of inlined properties; and guard behavior when parent lacks oneOf.
Generated model snapshot updates
tests/__snapshots__/mock/discriminator-oneof-allof/model/item1.ts, .../item2.ts, .../item3.ts
Updated generated model snapshots to remove Omit<DiscriminatorTest, 'type'> inheritance and instead emit direct property intersections like { property?: string } & { type: ItemType }, removing the DiscriminatorTest import dependency.
Generated mock endpoints and model snapshots for inherited fixture
tests/__snapshots__/mock/discriminator-oneof-allof-inherited/endpoints.ts, .../model/*
Added generated axios wrapper, MSW handler, faker-based mock builders, and new model snapshot files (animal, animalSpecies, cat, catSpecies, dog, dogSpecies, and index) for the discriminator-oneof-allof-inherited fixture.
Test config, typecheck script, and OpenAPI fixture
tests/configs/mock.config.ts, tests/scripts/typecheck-generated.mjs, tests/specifications/discriminator-oneof-allof-inherited.yaml
Added a new mock config entry for the inherited fixture, removed the exclusion preventing typechecking of the discriminator-oneof-allof mock fixture, and added the OpenAPI YAML fixture that drives the generated mocks and models.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

openapi, bug

Suggested reviewers

  • snebjorn
  • melloware

Poem

🐰 I dug through schemas late at night,
Inlined the parent's fields just right.
No circular plight,
Types compile bright—
Hopping home with CI all green and light! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title accurately describes the main fix: breaking circular type aliases in discriminator variants using allOf inheritance.
Linked Issues check ✅ Passed Code changes fully implement the solution for #3432 by inlining parent properties into variants to eliminate Omit dependency.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the circular type alias issue: core logic, test coverage, snapshot updates, and typecheck configuration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR addresses a TypeScript circular type-alias issue caused by discriminator parents that define oneOf variants which themselves inherit from the parent via allOf, and updates related mock snapshots.

Changes:

  • Adds schema-level rewriting in resolveDiscriminators to remove/inline a parent $ref within variant allOf to break TS2456 cycles.
  • Updates mock generator snapshots for discriminator-oneof-allof to reflect the new, non-circular variant shapes.
  • Removes the typecheck exclusion for the discriminator-oneof-allof mock fixture.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/scripts/typecheck-generated.mjs Removes mock fixture exclusion so it is now included in typechecking.
tests/snapshots/mock/discriminator-oneof-allof/model/item1.ts Updates snapshot to no longer depend on Omit<DiscriminatorTest, 'type'>.
tests/snapshots/mock/discriminator-oneof-allof/model/item2.ts Updates snapshot to no longer depend on Omit<DiscriminatorTest, 'type'>.
tests/snapshots/mock/discriminator-oneof-allof/model/item3.ts Updates snapshot to no longer depend on Omit<DiscriminatorTest, 'type'>.
packages/core/src/getters/discriminators.ts Implements the parent-$ref rewrite/inline logic for variants to break circular type aliases.
packages/core/src/getters/discriminators.test.ts Adds regression tests covering rewrite/inline behavior and a guard case.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +185 to +193
if (hasInheritableProps) {
rewritten.push({
type: 'object',
properties: inheritableProps,
...(inheritableRequired && inheritableRequired.length > 0
? { required: inheritableRequired }
: {}),
} as OpenApiSchemaObject);
}
Comment on lines +129 to +139
const inheritableProps: Record<
string,
OpenApiSchemaObject | OpenApiReferenceObject
> = {};
if (parentProperties) {
for (const [key, value] of Object.entries(parentProperties)) {
if (key !== propertyName) {
inheritableProps[key] = value;
}
}
}
Comment on lines +381 to +384
it('inlines parent non-discriminator properties into variant allOf (#3432)', () => {
// When the parent has additional properties beyond the discriminator key,
// those properties must survive on each variant. Replace the $ref with an
// inline object carrying parent's properties minus the discriminator key.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/src/getters/discriminators.test.ts (1)

381-437: ⚡ Quick win

Add a regression case for non-property parent constraints during rewrite.

Current #3432 tests validate ref removal and property inlining, but not whether parent-level constraints (like additionalProperties or required-only contributions) survive when the parent $ref is replaced. A focused case here would lock that behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/getters/discriminators.test.ts` around lines 381 - 437, The
test needs a regression case ensuring parent-level constraints survive when the
parent $ref is inlined by resolveDiscriminators: update the Parent schema in
discriminators.test.ts to include a parent-level constraint (e.g.,
additionalProperties: false and/or other non-property constraints) and assert
after calling resolveDiscriminators that the inlined object (inspect
variantA.allOf[0] as done currently) preserves those constraints
(expect(inlined).toHaveProperty('additionalProperties', false) and that required
remains exactly the parent's required list); ensure you reference the existing
resolveDiscriminators call and the variantA/allOf inspection logic so the new
assertions are added to the same test case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/getters/discriminators.ts`:
- Around line 125-143: When replacing a parent $ref inside variant `allOf` the
code currently only copies filtered `properties`/`required` (variables
parentProperties, parentRequired, inheritableProps, inheritableRequired,
hasInheritableProps) which drops other parent constraints; update the merge so
that after removing the parent `$ref` you shallow-merge all remaining keys from
`parentSchema` (e.g., additionalProperties, all composition/object constraints,
patternProperties, min/max*, etc.) into the variant schema except for the
removed property-specific entries — preserve every parent constraint not
explicitly overridden by the variant rather than only copying `properties` and
`required`.

---

Nitpick comments:
In `@packages/core/src/getters/discriminators.test.ts`:
- Around line 381-437: The test needs a regression case ensuring parent-level
constraints survive when the parent $ref is inlined by resolveDiscriminators:
update the Parent schema in discriminators.test.ts to include a parent-level
constraint (e.g., additionalProperties: false and/or other non-property
constraints) and assert after calling resolveDiscriminators that the inlined
object (inspect variantA.allOf[0] as done currently) preserves those constraints
(expect(inlined).toHaveProperty('additionalProperties', false) and that required
remains exactly the parent's required list); ensure you reference the existing
resolveDiscriminators call and the variantA/allOf inspection logic so the new
assertions are added to the same test case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d3d47ddc-6d0b-4930-a416-de340be491d2

📥 Commits

Reviewing files that changed from the base of the PR and between 495864c and cf8cae8.

📒 Files selected for processing (6)
  • packages/core/src/getters/discriminators.test.ts
  • packages/core/src/getters/discriminators.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item1.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item2.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item3.ts
  • tests/scripts/typecheck-generated.mjs
💤 Files with no reviewable changes (1)
  • tests/scripts/typecheck-generated.mjs

Comment thread packages/core/src/getters/discriminators.ts
@wadakatu
wadakatu marked this pull request as draft May 25, 2026 01:24
@wadakatu
wadakatu marked this pull request as ready for review May 25, 2026 02:55
wadakatu added 3 commits May 25, 2026 11:55
…or variants

When a discriminator parent has top-level `oneOf` listing variants that inherit
via `allOf: [{ $ref: <parent> }, ...]`, the type generator emitted
`type ParentN = (Variant1 & {...}) | ...` together with
`type Variant1 = Omit<Parent, key> & {...}`, producing TS2456: Type alias
'Variant1' circularly references itself.

Rewrite each variant's `$ref` back to the parent inside `resolveDiscriminators`:
inline the parent's non-discriminator properties (or drop the entry entirely
when the parent contributes nothing beyond the discriminator key). The variant
no longer depends on the parent's alias, breaking the cycle.

Other discriminator+oneOf+allOf fixtures (`recursive-discriminator-allof`,
`lowercase-discriminator`, `one-of-nested`, `polymorphic`) are unaffected:
either the parent has no top-level `oneOf`, the variants don't reference the
parent in their `allOf`, or the variants aren't mapping targets.

Closes orval-labs#3432
…props branch

The companion `discriminator-oneof-allof` fixture exercises the empty-parent
drop branch of the fix (parent carries only the discriminator key, so the
$ref-to-parent is dropped entirely from each variant). This fixture pins the
inline branch instead: `Animal` has both the discriminator key (`species`) and
a common property (`name`) that must be inherited by each variant.

Without the fix, `Cat` and `Dog` would emit as `Omit<Animal, 'species'> & ...`
and circularly depend on `Animal`'s alias union. With the fix, `name` is
inlined into each variant, breaking the cycle while preserving the property.

Refs orval-labs#3432
…rval-labs#3432)

Address review feedback on the orval-labs#3432 inline-parent-props rewrite:

- Shallow-copy the parent schema and only strip the keys that would re-create
  the cycle (oneOf, discriminator, allOf, anyOf), so object-level constraints
  like additionalProperties, minProperties, description, etc. carry through to
  the inlined entry. The previous implementation only kept
  type/properties/required, silently dropping every other constraint.
- Per-variant shallow-clone of properties and required so downstream in-place
  mutations on one variant don't leak across siblings under the same parent.
- Drop the inline entry entirely when nothing meaningful beyond type:'object'
  survives — the second allOf member (variant's own object) already asserts
  object-ness, and dropping keeps the existing empty-parent snapshot stable.

Adds two focused unit tests: one asserts that additionalProperties:false,
minProperties, and description propagate from parent to inlined variant
(and that oneOf/discriminator/allOf do not), the other asserts that sibling
variants get independent properties objects.
@wadakatu
wadakatu force-pushed the fix/3432-discriminator-circular-type-alias branch from 983db8b to 32bb1ea Compare May 25, 2026 02:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/core/src/getters/discriminators.ts (1)

194-195: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve non-cyclic parent composition constraints when inlining.

At Line 194-195, deleting allOf/anyOf unconditionally can silently drop valid parent constraints (e.g., parent inheriting shared fields via allOf). That changes variant semantics beyond just breaking the cycle.

Suggested fix direction
-        delete (inlinedParent as Record<string, unknown>).allOf;
-        delete (inlinedParent as Record<string, unknown>).anyOf;
+        const isRefToParent = (ref: string): boolean => {
+          try {
+            const originalName = getRefInfo(ref, context).originalName;
+            return (
+              originalName === parentName ||
+              pascal(originalName) === pascal(parentName)
+            );
+          } catch {
+            return false;
+          }
+        };
+
+        const sanitizeComposition = (
+          entries?: (OpenApiSchemaObject | OpenApiReferenceObject)[],
+        ) =>
+          entries?.filter(
+            (entry) => !isReference(entry) || !entry.$ref || !isRefToParent(entry.$ref),
+          );
+
+        const preservedAllOf = sanitizeComposition(
+          parentSchema.allOf as (OpenApiSchemaObject | OpenApiReferenceObject)[] | undefined,
+        );
+        const preservedAnyOf = sanitizeComposition(
+          parentSchema.anyOf as (OpenApiSchemaObject | OpenApiReferenceObject)[] | undefined,
+        );
+
+        if (preservedAllOf?.length) inlinedParent.allOf = preservedAllOf.map((e) => ({ ...e }));
+        else delete (inlinedParent as Record<string, unknown>).allOf;
+        if (preservedAnyOf?.length) inlinedParent.anyOf = preservedAnyOf.map((e) => ({ ...e }));
+        else delete (inlinedParent as Record<string, unknown>).anyOf;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/getters/discriminators.ts` around lines 194 - 195, The
current unconditional deletes of allOf/anyOf on inlinedParent drop legitimate
parent composition constraints; instead, detect and remove only the composition
entries that cause the cyclic reference to the inlined variant. Locate the
inlining logic around inlinedParent and replace the unconditional delete of
(inlinedParent as Record<string, unknown>).allOf / anyOf with code that:
inspects those arrays, filters out only the elements that reference the
child/variant being inlined (by $ref or identifier used elsewhere in this
module), keeps other entries, and only deletes the property if the resulting
array is empty; this preserves non-cyclic parent constraints while breaking only
the actual cyclic reference.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/core/src/getters/discriminators.ts`:
- Around line 194-195: The current unconditional deletes of allOf/anyOf on
inlinedParent drop legitimate parent composition constraints; instead, detect
and remove only the composition entries that cause the cyclic reference to the
inlined variant. Locate the inlining logic around inlinedParent and replace the
unconditional delete of (inlinedParent as Record<string, unknown>).allOf / anyOf
with code that: inspects those arrays, filters out only the elements that
reference the child/variant being inlined (by $ref or identifier used elsewhere
in this module), keeps other entries, and only deletes the property if the
resulting array is empty; this preserves non-cyclic parent constraints while
breaking only the actual cyclic reference.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ac46f1fc-f2a6-4735-a855-9acde8a520c9

📥 Commits

Reviewing files that changed from the base of the PR and between cf8cae8 and 32bb1ea.

📒 Files selected for processing (16)
  • packages/core/src/getters/discriminators.test.ts
  • packages/core/src/getters/discriminators.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/endpoints.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/animal.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/animalSpecies.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/cat.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/catSpecies.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/dog.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/dogSpecies.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof-inherited/model/index.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item1.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item2.ts
  • tests/__snapshots__/mock/discriminator-oneof-allof/model/item3.ts
  • tests/configs/mock.config.ts
  • tests/scripts/typecheck-generated.mjs
  • tests/specifications/discriminator-oneof-allof-inherited.yaml
💤 Files with no reviewable changes (1)
  • tests/scripts/typecheck-generated.mjs
✅ Files skipped from review due to trivial changes (10)
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/dog.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/dogSpecies.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/animal.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/animalSpecies.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/catSpecies.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/model/cat.ts
  • tests/snapshots/mock/discriminator-oneof-allof-inherited/endpoints.ts
  • tests/snapshots/mock/discriminator-oneof-allof/model/item3.ts
  • tests/snapshots/mock/discriminator-oneof-allof/model/item2.ts
  • tests/snapshots/mock/discriminator-oneof-allof/model/item1.ts

@melloware melloware added the openapi OpenAPI related issue label May 25, 2026
@melloware
melloware merged commit 26064de into orval-labs:master May 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

openapi OpenAPI related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants