Skip to content

fix(downgrader): merge an anyOf of one type and null into nullable in 3.1 to 3.0 - #87

Merged
dinwwwh merged 2 commits into
mainfrom
claude/project-thread-mlwjfb
Oct 24, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/project-thread-mlwjfb

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 24, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Problem

Zod writes .nullable() on objects, arrays, integers, and formatted strings as anyOf: [X, { type: "null" }]. In 3.1 to 3.0, that stayed an anyOf with the null-only branch, anyOf: [X, { type: "string", nullable: true, enum: [null] }]. NSwag 14.7 turns it into empty classes, so a .NET client fails at runtime on valid JSON. Kiota and oapi-codegen make wrapper or union types. Zod's own openapi-3.0 target writes X with nullable: true instead.

Change

When a schema's anyOf has exactly two branches, one of them the null-only branch, the other branch now replaces the anyOf with nullable: true. It has to meet these conditions:

  • it has a string type
  • it has no $ref, enum, allOf, anyOf, oneOf, not, or nullable. Null could still fail any of those beside nullable: true. Wrapping $refs in allOf with nullable broke oapi-codegen and datamodel-codegen, and putting null in an enum breaks openapi-python-client and oapi-codegen, so those cases keep the anyOf.
  • it shares no keyword with the schema around it
downgradeSchemaV31ToV30({ anyOf: [{ type: 'object', properties: { a: { type: 'string' } } }, { type: 'null' }] })
// before: { anyOf: [{ type: 'object', properties: { a: { type: 'string' } } }, { type: 'string', nullable: true, enum: [null] }] }
// after:  { type: 'object', properties: { a: { type: 'string' } }, nullable: true }

Merges run once the whole input has been converted. In a cyclic input, such as a dereferenced recursive parent: Node | null, a cycle can reach a branch before that branch finishes converting. Merging at that point would copy a partial schema. Running the merges at the end avoids this without per-schema bookkeeping. A WeakSet of finished schemas made 3.1 to 3.0 about 12% slower. Deferring the merges keeps speed the same as main: on the generated 100-resource API, the corpus, and the oRPC doc, the medians over 300 shuffled runs were within noise.

The README gains a row for the new conversion. It also gains a limitation: datamodel-code-generator reads a required nullable: true property as never null unless --strict-nullable is passed. I checked this with datamodel-codegen. It already applies to type: [X, "null"] output.

Verification

  • pnpm test: 255 pass, and coverage stays at 100% statements and branches. One existing expectation changed: anyOf: [string, null] now gives { type: "string", nullable: true }. New tests cover merged shapes, kept shapes, and the cycle case. The cycle test fails if the merge runs inside finishSchema.
  • Fuzz against main with Ajv in draft-04 mode, which honors nullable: 2,176 nullable anyOf schemas with varied outer keywords, branch orders, and null spellings. 462 outputs changed, and none of them changed which values are accepted.
  • pnpm run check passes.

Generated by Claude Code

… 3.1 to 3.0

Zod writes a nullable object, array, or integer as `anyOf: [X, { type: "null" }]`.
3.0 kept that as an `anyOf` with the null-only branch, which NSwag turns
into empty classes, and Kiota and oapi-codegen into wrapper types. The
branch now takes the `anyOf`'s place with `nullable: true`, as Zod's own
3.0 target writes it, unless it has a `$ref`, an `enum`, an applicator,
or `nullable`, or shares a keyword with the schema around it.

Merges run once the whole input is converted, so a branch a cycle meets
before its conversion is done is merged in full.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoFPNQaj4gdNwMyinVvha7
@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-mlwjfb (6ec102b) with main (cfae3e1)

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 critical issues — one minor doc gap.

Reviewed changes

  • Nullable-union merge — a deferred post-conversion pass (downgradeV30 draining twoBranchAnyOfs into mergeNullable) collapses a two-branch anyOf of one single-type branch and the canonical null branch into that branch plus nullable: true, skipping branches with $ref/allOf/anyOf/enum/not/nullable/oneOf or a key shared with the surrounding schema.
  • Tests — new cases for merged shapes, kept shapes (no shared key, unsupported keywords, three branches), and a cyclic parent: Node | null; the existing anyOf: [string, null] expectation now expects { type: 'string', nullable: true }.
  • README — new schema-downgrade row for the conversion plus a datamodel-code-generator --strict-nullable limitation note.

The merge is semantically sound: the surviving branch's non-type keywords are type-scoped and do not apply to null, and the branch type is a non-null string, so null still only enters via nullable. The shared-key guard is load-bearing — it stops Object.assign from overwriting an outer keyword (e.g. an inner type: ['object','null'] alongside an anyOf of {type:'integer'} and null, where a merge would wrongly widen acceptance to integers). Deferring to a post-pass correctly avoids copying a partially converted branch in cycles. Verified: 255 tests pass, snapshots unchanged, pnpm run check clean.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/downgrader/README.md Outdated
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoFPNQaj4gdNwMyinVvha7
@dinwwwh
dinwwwh merged commit 30a428b into main Oct 24, 2026
11 checks passed
dinwwwh added a commit that referenced this pull request Oct 24, 2026
…oneOf in 3.1 to 3.0 (#88)

<!-- claude-projects-attribution: {"github_login":"dinwwwh"} -->
_Requested by **dinwwwh** · [project
thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEFygEMePkCPrmjzGyxpMkUe)_

After #87, an `anyOf` of null and a branch that can't take `nullable:
true` stays an `anyOf` pair in 3.0. That covers a `$ref` (Zod
`Ref.nullable()`, pydantic `Optional[Model]`, oRPC's self-referencing
`Category.parent`) and a typed `enum` (`z.enum([...]).nullable()`).
NSwag 14.7 reads that pair as an empty class, so dotnet clients fail at
runtime. It reads a `oneOf` as the nullable type and round-trips.

The `oneOf` means the same thing here. Null matches only the null
branch. A branch with a `type` and no `nullable: true` never matches
null. So at most one branch matches any value.

- `rejectsNull(schema)`: a record with no `nullable: true` whose `type`
is a string other than `"null"`, or a non-empty array of strings without
`"null"`.
- `convertSchemaRef` records each `$ref` it keeps as written when its
target rejects null. It checks the 3.1 target, because in a cycle the
converted target may not be finished yet.
- `mergeNullable`: when the merge doesn't apply, `oneOf` is unset, and
the other branch is a recorded `$ref` or rejects null, the `anyOf`
becomes a `oneOf`.

Other generators the issue hunt checked (openapi-typescript, hey-api,
oapi-codegen, datamodel-codegen, openapi-python-client, Kiota,
openapi-generator typescript-fetch) produce the same code either way.

Tests: the typed `enum`/`allOf`/`not` branches and the keyword-clash
case now expect `oneOf`. A dangling `$ref`, a branch with `nullable`,
and an untyped branch stay `anyOf`. So does a pair beside an existing
`oneOf`. A new test covers a `$ref` to a component typed `object`
(becomes `oneOf`), and to components typed `["object", "null"]` or
carrying a 3.0-style `nullable` (stay `anyOf`). The oRPC snapshot
changes in two places: `Category.parent` and the `Planet | null`
response. README row updated. The 3.1 to 3.0 bench shows no change
beyond noise.

---
_Generated by [Claude
Code](https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
dinwwwh added a commit that referenced this pull request Oct 24, 2026
…3.0 nullable merge (#90)

<!-- claude-projects-attribution: {"github_login":"dinwwwh"} -->
_Requested by **dinwwwh** · [project
thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYE6GXWVbDpgbhjLdZd5YRnn1)_

Test-only. Stryker on main left mutants alive in the checks #87/#88
added to the 3.1 to 3.0 nullable merge. This adds expectations to the
existing tests in `tests/v3.1-to-v3.0.test.ts`; main already gives the
right output for each one.

- `anyOf` of a `$ref` to a `type: "null"` target and null stays `anyOf`.
This covers `rejectsNull` letting `"null"` through.
- `anyOf` of a `$ref` to a `type: ["object", "string"]` target and null
becomes `oneOf`. This covers the array `type` case of `rejectsNull`.
- A typed branch with `oneOf` beside null is not merged and becomes a
`oneOf` pair. This covers `oneOf` in `NOT_NULLABLE_KEYWORDS`.
- A null branch with a `description` is not treated as only null, so the
`anyOf` stays as it is. This covers the key count in `isOnlyNullSchema`.

I applied each of those four mutations by hand. All of them pass the
tests on main and fail with this change.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01KnRgKQZ6Maz5HiVMYr9twz)_

Co-authored-by: Claude <noreply@anthropic.com>
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