Skip to content

fix(downgrader): merge the literals of each type in a mixed-type union in 3.1 to 3.0 - #135

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-fz1zu8
Oct 26, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-fz1zu8

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

Zod, ArkType and Valibot write a union of literals mixed with other types, such as z.union([z.literal('a'), z.literal('b'), z.number()]) or 'true' | 'false' | boolean, as an anyOf with one const branch per literal. utoipa writes the unit variants of a Rust enum beside its other variants the same way.

mergeEnums only merged unions whose enums all share one type, so these stayed an anyOf of one-value enums in 3.0. The new mergeEnumsByType merges the enums of each type into one at the first one's place, when they hold nothing but type and enum (or keywords the schema around them repeats). scalarOneOf then makes a oneOf of a union of scalars, as it already does for string | number.

// 3.1 (Zod)
{ "anyOf": [{ "type": "string", "const": "a" }, { "type": "string", "const": "b" }, { "type": "number" }] }
// 3.0 before
{ "anyOf": [{ "type": "string", "enum": ["a"] }, { "type": "string", "enum": ["b"] }, { "type": "number" }] }
// 3.0 after
{ "oneOf": [{ "type": "string", "enum": ["a", "b"] }, { "type": "number" }] }

A oneOf whose branches share a value stays as it is, since merging would let that value through. An enum or another type beside the union keeps it as before.

Why

  • NSwag reads the anyOf as an empty class, and the oneOf as the first branch.
  • OpenAPI Generator 7.26 (typescript-fetch) turns the anyOf into empty interfaces, such as export interface ObjBase {}, and types a query parameter as an empty interface too. With the fix it gives export type ObjBase = number | string with enum value checks. Its Python client gets two oneOf validators instead of one per literal; Go is unchanged.
  • Kiota turns a nullable one into only the null sentinel enum.
  • oapi-codegen and openapi-python-client make one type per literal.
  • oasdiff reports a false breaking change when a literal is added, since the 3.0 output flips between oneOf and anyOf.

Real documents it changes include GrowthBook and Chroma.

Checks

  • 85 of 1,490 corpus documents change, with 0 values lost or widened (checked by validating sample values against the 3.1 input and the 3.0 output); the literal union fuzz also gives 0 lost and 0 widened.
  • A separate Ajv fuzz of 30,000 random unions of literals, scalar types, enums and objects (with anyOf/oneOf, nesting and repeated descriptions) changes 5,162 outputs, with 0 values lost or widened. Dropping the oneOf repeated-value bail-out makes the same fuzz report 64 lost and 1,639 widened, so it does reach that case.
  • pnpm run check and pnpm test pass (276 tests), with 100% statement, branch, function and line coverage of the downgrader.
  • The README const row gets one sentence on the new merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_016JU51h5pQGFxxMWjNUcAMz

Zod, ArkType and Valibot write `'a' | 'b' | number` as an `anyOf` with a
`const` branch for each literal, and utoipa writes the unit variants of a
Rust enum beside its other variants. mergeEnums only merged unions whose
enums all share one type, so these stayed an `anyOf` of one-value enums,
which NSwag reads as an empty class and oapi-codegen and
openapi-python-client as one type per literal. The enums of each type now
become one at the first one's place, so scalarOneOf can make a `oneOf` of a
union of scalars. A `oneOf` whose branches share a value stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JU51h5pQGFxxMWjNUcAMz
@dinwwwh dinwwwh self-assigned this Oct 26, 2026
@codecov

codecov Bot commented Oct 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing claude/project-thread-fz1zu8 (dfa3756) with main (aae889f)

Open in CodSpeed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • mergeEnumsByType — new helper in packages/downgrader/src/v3.1-to-v3.0.ts:794 that groups the eligible enum branches of a mixed-type anyOf/oneOf by branch.type and merges each group's values into one branch at that type's first position, leaving ineligible branches and the union otherwise as they were.
  • mergeEnums dispatch — when enumUnionType is undefined (the union's non-null branches are not all enums of one type), it now delegates to mergeEnumsByType instead of returning; the out.type/out.enum guard still runs first, so an enum or a differing type beside the union keeps it unchanged.
  • Tests — a mixed literal union becomes a oneOf of one enum per type, a oneOf with a shared value is left intact, and a branch carrying a non-repeating keyword stays separate.
  • README — one sentence added to the const row.

Notes

The merge is an append/union operation, and the oneOf path aborts the whole rewrite when a value repeats within a type group (merging would let that value match a single branch). I confirmed acceptance parity independently: 21,580 generated anyOf/oneOf combinations of mixed const literals, including duplicate values and null branches, produced zero widening and zero loss against a 10-value domain. pnpm test (280 tests) and pnpm run check --only tsc both pass.

One non-blocking observation: a mixed-type union nested inside a oneOf branch that scalarOneOf later flattens is not itself merged (its members are already a oneOf, so the grouping step skips it). That is a missed optimization rather than a regression, and the resulting document is still correct.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit b10f7fa into main Oct 26, 2026
11 checks passed
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.

2 participants