Repository navigation
fix(downgrader): merge an anyOf of one type and null into nullable in 3.1 to 3.0 - #87
Conversation
… 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
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
ℹ️ No critical issues — one minor doc gap.
Reviewed changes
- Nullable-union merge — a deferred post-conversion pass (
downgradeV30drainingtwoBranchAnyOfsintomergeNullable) collapses a two-branchanyOfof one single-typebranch and the canonical null branch into that branch plusnullable: true, skipping branches with$ref/allOf/anyOf/enum/not/nullable/oneOfor 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 existinganyOf: [string, null]expectation now expects{ type: 'string', nullable: true }. - README — new schema-downgrade row for the conversion plus a datamodel-code-generator
--strict-nullablelimitation 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.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VoFPNQaj4gdNwMyinVvha7
…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>
…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>

Requested by dinwwwh · project thread
Problem
Zod writes
.nullable()on objects, arrays, integers, and formatted strings asanyOf: [X, { type: "null" }]. In 3.1 to 3.0, that stayed ananyOfwith 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 ownopenapi-3.0target writesXwithnullable: trueinstead.Change
When a schema's
anyOfhas exactly two branches, one of them the null-only branch, the other branch now replaces theanyOfwithnullable: true. It has to meet these conditions:type$ref,enum,allOf,anyOf,oneOf,not, ornullable. Null could still fail any of those besidenullable: true. Wrapping$refs inallOfwithnullablebroke oapi-codegen and datamodel-codegen, and putting null in an enum breaks openapi-python-client and oapi-codegen, so those cases keep theanyOf.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. AWeakSetof finished schemas made 3.1 to 3.0 about 12% slower. Deferring the merges keeps speed the same asmain: 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: trueproperty as never null unless--strict-nullableis passed. I checked this with datamodel-codegen. It already applies totype: [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 insidefinishSchema.mainwith Ajv in draft-04 mode, which honorsnullable: 2,176 nullableanyOfschemas with varied outer keywords, branch orders, and null spellings. 462 outputs changed, and none of them changed which values are accepted.pnpm run checkpasses.Generated by Claude Code