Skip to content

fix(core): extract named enum type for anyOf nullable enum composition - #3424

Merged
melloware merged 2 commits into
orval-labs:masterfrom
wadakatu:fix/issue-2710-anyof-enum-null
May 22, 2026
Merged

fix(core): extract named enum type for anyOf nullable enum composition#3424
melloware merged 2 commits into
orval-labs:masterfrom
wadakatu:fix/issue-2710-anyof-enum-null

Conversation

@wadakatu

@wadakatu wadakatu commented May 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #2710.

OpenAPI 3.1 lets users spell a nullable enum either as {type: ['string','null'], enum: [...]} (which orval already extracts as a named type) or as anyOf: [{enum: [...]}, {type: 'null'}] (FastAPI emits this form). The second spelling silently fell back to an inline union for query parameters, request body properties, and component schemas:

// Before
export type ExampleParams = {
  status?: 'new' | 'in_progress' | null   // inline
  order_by?: ExampleOrderBy               // sibling plain enum gets extracted
}

// After
export type ExampleParams = {
  status?: ExampleStatus                  // named, matches order_by
  order_by?: ExampleOrderBy
}
export type ExampleStatus = 'new' | 'in_progress' | null;

Root cause

combineSchemas decided whether to treat the result as an enum via:

const isAllEnums = resolvedData.isEnum.every(Boolean);

The {type: 'null'} branch resolves to isEnum: false, so the strict every() collapsed to false. The fall-through return path hard-coded isEnum: false, which made downstream callers (query-params.ts, schema-definition.ts, resolvers/object.ts) skip their isEnum && !isRef extraction branch and inline the union instead.

Fix

Detect the nullable enum composition pattern — every variant is either an enum or a {type: 'null'} branch, with at least one enum — and propagate isEnum: true on that single return path. No new code path needed: getEnum's existing stripNullUnion handling in getTypeConstEnum already moves the trailing | null off the const body and onto the type alias, so UNION and CONST modes both produce correct output.

  const isAllEnums = resolvedData.isEnum.every(Boolean);
+ const isNullableEnumComposition =
+   !isAllEnums &&
+   resolvedData.isEnum.some(Boolean) &&
+   resolvedData.isEnum.every(
+     (isEnum, index) => isEnum || resolvedData.types[index] === 'null',
+   );

  // ... existing return ...
-   isEnum: false,
+   isEnum: isNullableEnumComposition,

The detection is intentionally narrow:

  • anyOf: [{enum}, {string}] (genuine union of enum and non-null scalar) → unchanged, stays inlined
  • anyOf: [{string}, {null}] (nullable non-enum) → unchanged, stays inlined
  • anyOf: [{enum}, {enum}, {null}] (multi-enum + null) → now extracted as a nullable enum (pinned by a test)

Scope of behavior change

Caller Mode Before After
query-params (the bug) UNION / CONST inline named type via getEnum
inline body property any inline named type via resolvers/object
component schema UNION export type X = 'a'|'b'|null identical
component schema CONST inlined union typeof+const pattern (now consistent with other nullable enums)

All 4133 snapshot tests in tests/ produce byte-identical output → no existing fixture is affected.

Tests

10 tests, all OAS 3.1, covering positive and negative cases:

combine.test.ts (unit, combineSchemas directly) — under describe('nullable enum composition (#2710)'):

  • anyOf [enum, null] → isEnum: true
  • oneOf [enum, null] → isEnum: true
  • anyOf [null, enum] (order reversed) → isEnum: true
  • anyOf [numeric enum, null] → isEnum: true
  • anyOf [enum, enum, null] (multi-enum + null) → isEnum: true
  • anyOf [string, null] (no enum) → isEnum: false
  • anyOf [enum, string] (no null) → isEnum: false

query-params.test.ts (integration):

  • anyOf [enum, null] → emits status?: Status; + named type with trailing | null
  • Under enumGenerationType: 'const' → emits typeof+const pattern, with explicit not.toContain('null: null') guard
  • anyOf [enum, non-null scalar] → stays inlined (over-match guard)

Verification

  • @orval/core: 1797 unit tests pass (1789 → 1797, +8)
  • packages/orval: 91 vitest tests pass
  • tests/: 4133 snapshot tests pass (byte-identical to master)
  • bun run lint, bun run typecheck: pass
  • Manual reproduction of the issue's exact schema now emits ExampleStatus as expected under both default (CONST) and enumGenerationType: 'union' modes

Out of scope

  • enumGenerationType: 'enum' (native TS enum) handling of nullable enums — pre-existing bug that emits an invalid null = null member, reproducible with {type: ['string','null'], enum: [..., null]} on master without this PR.
  • OAS 3.0 nullable: true siblings inside anyOf — the @scalar/openapi-parser upgrader does not convert those to {type: 'null'}, so they remain inlined. Separate concern.

Summary by CodeRabbit

  • Bug Fixes

    • Detect and treat OpenAPI 3.1 enum+null compositions (anyOf/oneOf) as nullable enums so generation correctly preserves enum semantics and nullable typing.
  • Tests

    • Added broad regression tests covering anyOf/oneOf nullable-enum cases (ordering, numeric enums, multiple inline enums) and negative scenarios to ensure correct handling and prevent regressions.

Review Change Stack

Copilot AI review requested due to automatic review settings May 22, 2026 13:28
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8483fd3d-f9d7-416e-92cc-54aa36b9464b

📥 Commits

Reviewing files that changed from the base of the PR and between 399db48 and 0c5e372.

📒 Files selected for processing (3)
  • packages/core/src/getters/combine.test.ts
  • packages/core/src/getters/combine.ts
  • packages/core/src/getters/query-params.test.ts

📝 Walkthrough

Walkthrough

This PR detects OpenAPI 3.1 nullable-enum compositions (inline enum + { type: 'null' } inside anyOf/oneOf) in combineSchemas and treats them as enum-like so named nullable enum types are generated; tests and query-param regressions were added.

Changes

Nullable Enum Composition Detection

Layer / File(s) Summary
Core nullable-enum detection logic
packages/core/src/getters/combine.ts
Computes isNullableEnumComposition by inspecting anyOf/oneOf branches for enum + type: 'null' patterns and uses this flag to set the returned isEnum value instead of unconditionally returning false.
combineSchemas unit tests
packages/core/src/getters/combine.test.ts
Adds a Status component in the shared test context and a vitest suite that validates nullable-enum detection for anyOf/oneOf (positive: order-agnostic, numeric, multiple enum branches; negative: scalar+null, $ref enum+null, allOf enum+null, enum+non-null scalar).
Query params regression tests
packages/core/src/getters/query-params.test.ts
Adds tests asserting named nullable enum extraction for anyOf/oneOf, const-enum emission (`typeof ...

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

openapi

Suggested reviewers

  • melloware

Poem

🐰 A rabbit peered at anyOf's little twist,
Enum met null and could no longer be missed.
Named types now blossom where unions once lay,
Tidy enums hop forth to brighten the day.

🚥 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 The title accurately summarizes the main change: extracting named enum types for anyOf nullable enum composition patterns in OpenAPI 3.1 schemas.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #2710 by implementing nullable enum composition detection and named type extraction for query parameters and inline properties, with extensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing issue #2710: detecting nullable enum patterns in combine.ts, adding tests in combine.test.ts and query-params.test.ts, with no unrelated modifications.

✏️ 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.

Adds support for OpenAPI 3.1 “nullable enum” compositions (anyOf/oneOf with an enum branch plus { type: 'null' }) so enums are extracted as named types (including const enum generation), matching the existing type: ['...', 'null'] enum behavior.

Changes:

  • Update combineSchemas to treat { type: 'null' } union members as transparent when determining “enum-ness” for nullable enum compositions.
  • Add integration tests for query params to ensure nullable enum extraction and correct const enum output.
  • Add unit tests for combineSchemas nullable-enum detection and non-matching negative cases.

Reviewed changes

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

File Description
packages/core/src/getters/query-params.test.ts Adds integration coverage for anyOf: [enum, null] query params (including const enum mode).
packages/core/src/getters/combine.ts Flags nullable enum compositions as isEnum so callers can extract a named enum type.
packages/core/src/getters/combine.test.ts Adds focused unit tests for nullable enum composition detection and negative cases.

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

Comment thread packages/core/src/getters/combine.ts Outdated
Comment on lines +356 to +359
!isAllEnums &&
resolvedData.isEnum.some(Boolean) &&
resolvedData.isEnum.every(
(isEnum, index) => isEnum || resolvedData.types[index] === 'null',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both concerns addressed in 399db48. Verified the anyOf: [{$ref: '.../Status'}, {type: 'null'}] repro: before the fix it emitted a nested ExampleStatus = { Status: Status } const; now it correctly emits status?: Status | null reusing the referenced type. Added (separator === 'anyOf' || separator === 'oneOf') and !resolvedData.isRef[index] guards to isNullableEnumComposition, plus negative unit tests for the $ref + null and allOf [enum, null] shapes.

? [...resolvedData.dependencies, ...resolvedValue.dependencies]
: resolvedData.dependencies,
isEnum: false,
isEnum: isNullableEnumComposition,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same fix as the L359 thread — addressed in 399db48. The detection now requires isUnionLikeSeparator (anyOf/oneOf only) and inline enum branches (!resolvedData.isRef[index]).

Comment on lines +93 to +97
describe('nullable enum composition (#2710)', () => {
it('flags anyOf [enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }],
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 399db48. Two new tests in combine.test.ts: does not flag anyOf [$ref enum, null] as a nullable enum (asserts isEnum: false), and an integration test in query-params.test.ts (queryParam with anyOf [$ref enum, null] reuses the referenced type) asserting the param emits status?: Status | null and no parameter-scoped enum is added to deps.

@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 (2)
packages/core/src/getters/query-params.test.ts (2)

201-305: ⚡ Quick win

Consider adding oneOf test coverage for completeness.

The PR description mentions support for both anyOf and oneOf nullable enum compositions, but the new tests only cover anyOf. While the behavior should be identical, adding a parallel test case for oneOf would provide explicit integration-level coverage and increase confidence that both composition keywords are handled consistently.

Example oneOf test case
it('queryParam with oneOf containing enum and null extracts a named nullable enum type', () => {
  const result = getQueryParams({
    queryParams: [
      {
        parameter: {
          name: 'priority',
          in: 'query',
          required: false,
          schema: {
            oneOf: [{ enum: ['low', 'high'] }, { type: 'null' }],
            title: 'Priority',
          },
        },
        imports: [],
      },
    ],
    operationName: '',
    context,
  });

  expect(result?.schema.model.trim()).toBe(
    `export type Params = {\npriority?: Priority;\n};`,
  );

  const priorityEnum = result?.deps.find((schema) => schema.name === 'Priority');
  expect(priorityEnum).toBeDefined();
  expect(priorityEnum?.model).toContain(`'low' | 'high' | null`);
});
🤖 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/query-params.test.ts` around lines 201 - 305, Add
parallel oneOf-based tests mirroring the anyOf cases to ensure oneOf
nullable-enum handling is covered: create a test that calls getQueryParams with
a queryParam whose schema uses oneOf: [{ enum: [...] }, { type: 'null' }]
(similar to the anyOf 'Status' test) and assert the Params model references the
named type and that the dependency (e.g., find by name 'Priority' or 'Status')
contains the enum union with | null; also add the const-mode variant using
createTestContextSpec with EnumGeneration.CONST to assert the typeof+const
pattern appears and that 'null: null' does not leak, and finally add a negative
test where oneOf contains enum plus a non-null scalar to confirm it stays
inlined (no named type emitted).

238-276: 💤 Low value

Well-designed test for const enum mode with nullable composition.

The defensive assertion at line 275 correctly prevents invalid null: null members in the const body. The test validates the typeof+const pattern and null handling.

Optional: Consider adding explicit const body verification

For completeness, you could add an assertion that the const body contains the expected enum values:

 expect(statusEnum?.model).toContain(`export const Status = {`);
+expect(statusEnum?.model).toContain(`new:`);
+expect(statusEnum?.model).toContain(`in_progress:`);
 // The null variant must not leak into the const body as a `null: null`
 // member — that would emit invalid TypeScript.
 expect(statusEnum?.model).not.toContain('null: null');

This would make the test more thorough, though the current assertions are sufficient for integration-level testing.

🤖 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/query-params.test.ts` around lines 238 - 276, Add
an explicit assertion to this test that the generated const body for the Status
enum contains the expected enum members so we verify the actual const entries
(e.g., that statusEnum.model includes the literal const mappings for "new" and
"in_progress"); locate the statusEnum variable (result?.deps.find((schema) =>
schema.name === 'Status')) and add checks on statusEnum.model toContain the
expected const entries (for example the string fragments representing the const
object members) in addition to the existing null and typeof assertions.
🤖 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/combine.ts`:
- Around line 347-360: The isNullableEnumComposition check should only apply to
union-like compositions, so add a guard that requires (separator === 'anyOf' ||
separator === 'oneOf') before the existing !isAllEnums && resolvedData.isEnum...
predicate; update the isNullableEnumComposition declaration (the variable named
isNullableEnumComposition) to include this separator check alongside isAllEnums
and the resolvedData.isEnum conditions so that allOf compositions are not
misclassified as nullable enums.

---

Nitpick comments:
In `@packages/core/src/getters/query-params.test.ts`:
- Around line 201-305: Add parallel oneOf-based tests mirroring the anyOf cases
to ensure oneOf nullable-enum handling is covered: create a test that calls
getQueryParams with a queryParam whose schema uses oneOf: [{ enum: [...] }, {
type: 'null' }] (similar to the anyOf 'Status' test) and assert the Params model
references the named type and that the dependency (e.g., find by name 'Priority'
or 'Status') contains the enum union with | null; also add the const-mode
variant using createTestContextSpec with EnumGeneration.CONST to assert the
typeof+const pattern appears and that 'null: null' does not leak, and finally
add a negative test where oneOf contains enum plus a non-null scalar to confirm
it stays inlined (no named type emitted).
- Around line 238-276: Add an explicit assertion to this test that the generated
const body for the Status enum contains the expected enum members so we verify
the actual const entries (e.g., that statusEnum.model includes the literal const
mappings for "new" and "in_progress"); locate the statusEnum variable
(result?.deps.find((schema) => schema.name === 'Status')) and add checks on
statusEnum.model toContain the expected const entries (for example the string
fragments representing the const object members) in addition to the existing
null and typeof assertions.
🪄 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: a1f8f80e-a65f-4496-8fa9-63e7b594e1c5

📥 Commits

Reviewing files that changed from the base of the PR and between 526666c and 3d4a7bd.

📒 Files selected for processing (3)
  • packages/core/src/getters/combine.test.ts
  • packages/core/src/getters/combine.ts
  • packages/core/src/getters/query-params.test.ts

Comment thread packages/core/src/getters/combine.ts
wadakatu added a commit to wadakatu/orval that referenced this pull request May 22, 2026
Addresses review feedback on orval-labs#3424:

The initial detection toggled `isEnum: true` whenever the variants were
"enum-or-null", but missed two cases that change the caller's behavior:

- `allOf` is an intersection, not a union. `allOf: [{enum}, {null}]` is
  semantically empty and must not be misclassified.
- When the enum branch is a `$ref` (e.g. `anyOf: [{$ref: '.../Status'},
  {type: 'null'}]`) the existing referenced enum should be reused.
  Routing through `getEnum` re-extracted the ref as a parallel inline
  enum, producing a nested `ExampleStatus = { Status: Status }` const
  instead of `status?: Status | null`.

Add two guards: (1) `separator === 'anyOf' || separator === 'oneOf'` and
(2) `!resolvedData.isRef[index]` for non-null branches. New negative
tests pin both shapes. Also add an `oneOf` integration test and
explicit const-body member assertions per CodeRabbit's nitpicks.
wadakatu added 2 commits May 22, 2026 22:52
OpenAPI 3.1 allows `anyOf: [{enum: [...]}, {type: 'null'}]` as a
nullable enum, but `combineSchemas` flagged the result as `isEnum: false`
because the `{type: 'null'}` branch broke the strict `isEnum.every()`
check. Downstream call sites (query-params, schema-definition,
resolvers/object) therefore inlined the union instead of extracting a
named type — diverging from the equivalent
`{type: ['string','null'], enum: [...]}` spelling, which already works.

Recognize the "every variant is enum-or-null, at least one enum" pattern
and propagate `isEnum: true` so the existing extraction paths produce a
named type. `getEnum`'s `stripNullUnion` handling already preserves the
trailing ` | null` for UNION and CONST modes.

Fixes orval-labs#2710
Addresses review feedback on orval-labs#3424:

The initial detection toggled `isEnum: true` whenever the variants were
"enum-or-null", but missed two cases that change the caller's behavior:

- `allOf` is an intersection, not a union. `allOf: [{enum}, {null}]` is
  semantically empty and must not be misclassified.
- When the enum branch is a `$ref` (e.g. `anyOf: [{$ref: '.../Status'},
  {type: 'null'}]`) the existing referenced enum should be reused.
  Routing through `getEnum` re-extracted the ref as a parallel inline
  enum, producing a nested `ExampleStatus = { Status: Status }` const
  instead of `status?: Status | null`.

Add two guards: (1) `separator === 'anyOf' || separator === 'oneOf'` and
(2) `!resolvedData.isRef[index]` for non-null branches. New negative
tests pin both shapes. Also add an `oneOf` integration test and
explicit const-body member assertions per CodeRabbit's nitpicks.
@wadakatu
wadakatu force-pushed the fix/issue-2710-anyof-enum-null branch from 399db48 to 0c5e372 Compare May 22, 2026 13:52
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.

Enum inside anyOf with null does not generate a type in generated hooks

3 participants