Skip to content

fix(downgrader): merge an anyOf with null twice, as Effect writes nullish, in 3.1 to 3.0 - #102

Open
dinwwwh wants to merge 1 commit into
mainfrom
claude/project-thread-tqic1l
Open

dinwwwh wants to merge 1 commit into
mainfrom
claude/project-thread-tqic1l

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 24, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Problem

Effect's S.NullishOr(X) and S.optional(S.NullOr(X)) come out of @orpc/effect as anyOf: [X, { type: "null" }, { type: "null" }]. The nullable merge from #87/#88 only looks at two-branch anyOfs, so 3.0 keeps the anyOf.

NSwag 14.7 on an oRPC Effect model (addr: S.NullishOr(S.Struct({ city })), tags: S.optional(S.NullOr(S.Array(S.String))), count: S.NullishOr(S.Int), name: S.optional(S.NullOr(S.String))): on main it makes empty wrapper classes, and the C# client fails on both a filled payload ("Cannot deserialize the current JSON array into type 'Tags'") and an all-null one ("Required property 'addr' expects a value but got null"). With this PR it makes a nullable class, ICollection<string>, long? and string, and both payloads deserialize. Kiota reads the kept anyOf as a string.

Change

  • finishSchema queues every anyOf with more than one branch (the list is renamed twoBranchAnyOfs to nullableAnyOfs).
  • mergeNullable merges when all branches but one are only-null.
  • When the pair becomes a oneOf, it keeps the other branch and one null branch, in their order, since a oneOf rejects null when it matches two branches. It picks by position, not identity, so a doc that uses one null object twice still gets null once.
  • Tests: the fix(downgrader): merge an anyOf of one type and null into nullable in 3.1 to 3.0 #87 test that pinned [string, null, null] as a kept anyOf moves to the merge test, plus a oneOf case for each order and [string, number, null] staying an anyOf. One README row.

Checks

  • 258 tests pass, pnpm run check clean.
  • 1,000 random Effect docs stay valid with no meaning lost. Zod/Valibot fuzz output and 79 of 80 real docs are byte-identical to main.
  • Speed (esbuild bundles, one process, gc and shuffled order, 250 rounds): generated api 100, path item chain and the corpus are within noise of main. A synthetic doc with 6,000 Effect nullish fields is about 15% slower, which is the cost of the merge it now gets.

Note: #99 also edits downgradeV30, so whichever merges second needs a small rebase for the rename.


Generated by Claude Code

…lish, in 3.1 to 3.0

Effect's `S.NullishOr(X)` and `S.optional(S.NullOr(X))` come out of
@orpc/effect as `anyOf: [X, { type: "null" }, { type: "null" }]`. The
nullable merge only looked at two-branch anyOfs, so 3.0 kept the anyOf,
which NSwag reads as an empty class and Kiota as a string. It now merges
any anyOf whose branches are all only-null but one, and when it becomes
a oneOf it keeps that branch and one null branch.

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

codecov Bot commented Oct 24, 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 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing claude/project-thread-tqic1l (8864a07) with main (6a906e9)

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

This run reviewed the full net diff against main — the extension of the 3.1→3.0 nullish-anyOf merge to unions with more than two branches.

  • Collect every multi-branch anyOf — finishSchema now queues out.anyOf when length > 1 (renamed twoBranchAnyOfs → nullableAnyOfs), so Effect's anyOf: [X, {type:"null"}, {type:"null"}] reaches mergeNullable.
  • Merge only a single non-null branch — mergeNullable filters out isOnlyNullSchema branches and proceeds only when exactly one non-null branch remains; the non-null count >= 2 case returns early, preserving genuine unions like [string, number, null].
  • Dedup null on the oneOf path — when the pair becomes a oneOf, it keeps other plus a single null branch, ordered by branches[0] === other, so null isn't duplicated (which would make the oneOf reject null).
  • Tests and README — the #87 assertion that pinned [string, null, null] as a kept anyOf moved to the merge test, with new per-order oneOf and [string, number, null] retention cases; one README table row updated.

I verified the diff against the surrounding converter: the 3-branch outputs match the equivalent 2-branch pattern for each path (merge→nullable, oneOf ordering, kept anyOf), the strict isOnlyNullSchema shape (exactly three keys, type: "string", nullable: true, enum: [null]) prevents any non-null branch from being misclassified as null, and the whole suite passes (vitest run — 5 files, 262 tests).

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

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