Repository navigation
feat(downgrader): turn a propertyNames enum into one property per name in 3.1 to 3.0 - #100
Conversation
…e in 3.1 to 3.0 Zod and Valibot write a record with enum keys as a propertyNames enum beside additionalProperties. 3.0 has no propertyNames, so generators typed it as a string-keyed map and an enclosing oneOf became anyOf. Each name now becomes a property sharing the additionalProperties schema, with additionalProperties: false. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…moved Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
There was a problem hiding this comment.
ℹ️ No critical issues — minor suggestions inline.
Reviewed changes
propertyNamesenum → one property per name —enumNamesdetects the Zod/Valibot record shape andfinishSchemarewrites it intoproperties+additionalProperties: false;losesRestrictiontreats it as exact so an enclosingoneOfstays.- Test — covers the Zod shape, the bare shape, the removal cases, and the enclosing
oneOf. - README — new 3.1→3.0 table row for the enum case.
I checked semantic equivalence with an Ajv 2020-12 differential (49 applicable schema combos × 56 instances): input and converted output agreed everywhere except __proto__ enum names, which is an Ajv quirk (reproducible with a hand-written properties: { "__proto__": {} } schema) rather than a converter defect. pnpm test passes (263) and uncheck is clean on the touched files.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — one minor documentation suggestion inline.
Reviewed changes
Since the prior review (dbb858f), cea5a3e adds assertions to the propertyNames enum test pinning the cases where the conversion does not apply: additionalProperties: false, patternProperties, and a propertyNames carrying an extra keyword (minLength, type: "number"), plus an enum beside description: undefined. No production code changed.
propertyNamesenum → one property per name —enumNamesdetects the Zod/Valibot record shape andfinishSchemarewrites it intoproperties+additionalProperties: false;losesRestrictiontreats it as exact so an enclosingoneOfstays.- Tests — the Zod shape, the bare shape, the exact-conversion cases, the removal cases (now including
additionalProperties: falseandpatternProperties), and the enclosingoneOf. - README — new 3.1→3.0 table row.
I re-ran the mutation check on the additionalProperties === false guard: with the new test in place, removing that guard now fails the suite (previously all 263 passed), so the guard is genuinely covered. pnpm test is green and uncheck is clean on the touched files.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
|
Follow-up to my review (the inline anchor did not attach): the new README row lists
|
|
Re the README row: I'm leaving it as is. With Generated by Claude Code |
Zod's .meta({ id }) enum keys and Pydantic dict[Enum, V] write a record's
keys as a $ref to a reusable enum. It is followed when nothing sits beside
it, so the record still gets one property per name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
There was a problem hiding this comment.
ℹ️ No critical issues — one minor documentation note inline.
Reviewed changes
Since the prior review (cea5a3e), a8c9208 extends the propertyNames enum conversion to follow a $ref.
$ref-keyedpropertyNames—enumNamesnow resolves a$refthat stands alone inpropertyNamesto its target and reads theenumfrom there, andctxthreads throughlosesRestriction/loosenso the looseness check sees the same resolved names the rewrite does.- Tests — a new case covers a
$refto a reusable enum incomponents/$defs, the enclosingoneOfstaying aoneOf, and the removals for a non-enum target and a$refcarrying a sibling keyword. - README — the 3.1→3.0 table row now names the
$refform.
pnpm test (264) and pnpm run check (sherif/oxlint/oxfmt/tsc) are green on this commit.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…propertyNames enum Zod z.literal and Valibot v.literal write a record's single key as a const, and .describe(), v.title, or a reusable enum's meta add a title or description beside the enum. Both still give one property per name in 3.0. A union of literal keys, an anyOf of consts, is listed as a limitation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Since the prior review (a8c9208), 5847cca widens the propertyNames conversion to also accept a const key and title/description annotations.
constkey —enumNamesnow treats a loneconstas a single name ([names.const]) when noenumsits beside it, so a Zodz.literal/ Valibotv.literalrecord key becomes one property. Aenumbeside aconststays on the removal path, since only the names in both would match; a non-stringconstreduces toproperties: {}+additionalProperties: false, which is exact because JSON property names are always strings.title/description— both are accepted as no-op annotation keys onpropertyNames(and on a followed$reftarget), then dropped in the output.- README — the 3.1→3.0 row now names
constand the dropped annotations, and a new limitation records that a union of literal keys (anyOfofconsts) still loses its names.
pnpm test (264) and uncheck (oxlint/oxfmt/tsc) are green on this commit; the const path is a single-value subset of the enum path already differential-tested against Ajv, and the looseness check agrees with the rewrite throughout.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…iption
Zod writes z.record(Lang.describe('key'), V) for a reusable enum as a $ref
with a description beside it, which does not narrow the names.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Since the prior review (5847cca), 06b568d follows a $ref in propertyNames even when a title or description sits beside it.
$refbeside annotations —enumNamesnow resolves the$refwhen the only otherpropertyNameskeys aretitle/description(both non-constraining annotations, dropped from the 3.0 output), so a described reusable-enum key converts instead of being removed. Other siblings still block following.- Test — a new case pins the described-use shape (
{ $ref, title, description }), along with the enclosingoneOfvariants.
pnpm test (264) and uncheck (oxlint/oxfmt/tsc) are green on this commit.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…pertyNames cases description is now allowed beside the names, so it no longer shows that a key holding undefined counts as missing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
…ema per name in 3.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
…ema with its target That additionalProperties becomes false in 3.0, so a $ref into or through it, as zod-to-json-schema and swagger-parser bundle() write, dangled or pointed at false. It is now inlined like a $defs ref. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
There was a problem hiding this comment.
ℹ️ No critical issues — one narrow edge case inline.
Reviewed changes
Since the prior review (06b568d), 0ed9fa0 makes isInlined replace a $ref that points into the value schema of a record keyed by an enum, so it no longer dangles once that record's additionalProperties becomes false.
$refthrough an enum-keyed record'sadditionalProperties—isInlinednow also inlines when the newisIntoNamedValuefinds an/additionalPropertiessegment whose holder has a convertiblepropertyNames, replacing the ref with its converted target instead of keeping it.- Test — pins the direct replacement plus the control case where a plain string-keyed record keeps its
additionalPropertiesand its$ref.
Verified the recursive value-schema case (a record whose additionalProperties refers back into itself) collapses to {} rather than cycling, and the downgrader test file passes (93).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
… true in 3.0 (#101) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEGx42KWDe4jcasnYt6imLXt)_ 3.0 has no `patternProperties`, so the 3.1 to 3.0 converter removes it, along with any `additionalProperties` beside it (a schema or `false` there would reject the properties `patternProperties` allowed). What's left is often a bare `{ type: "object" }`, and openapi-typescript types that as `Record<string, never>`. Code generators read a missing `additionalProperties` as "no other properties allowed". This shows up in real output: - Zod `z.looseRecord` (`patternProperties`, no `additionalProperties`) - Effect 4 through `@orpc/effect`: `Schema.Record(Schema.Number, V)`, template-literal key records and `StructWithRest` (`additionalProperties: true`), and records with `onExcessProperty: "error"` (`additionalProperties: false`) - `patternProperties` beside an `additionalProperties` schema, or beside `properties` - SendGrid's `contacts/search/emails` response Now `finishSchema` sets `additionalProperties: true` whenever `patternProperties` is present. The meaning doesn't change: a missing `additionalProperties` already allowed every property, and the schema is still marked as having lost a restriction. - `src/v3.1-to-v3.0.ts`: the `additionalProperties = true` step in `finishSchema`. The field converter still drops the input `additionalProperties`, as on main. - `README.md`: the `patternProperties` row says `additionalProperties` becomes `true`. - `tests/v3.1-to-v3.0.test.ts`: the existing test checks `additionalProperties` set to undefined, `true`, `false` and a schema, plus a case with `properties`. It fails without the fix. Checks: 262 tests pass and `pnpm run check` is clean. Note: #100's test expects `{ propertyNames: { enum }, patternProperties }` to give `{}`. With this change it gives `{ additionalProperties: true }`, so whichever PR merges second needs that expectation updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FZoWKNpDyGBHkdaur4kKtA --------- Co-authored-by: Claude <noreply@anthropic.com>
#101 gives a schema with patternProperties additionalProperties: true, so the propertyNames enum case beside patternProperties now expects it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011jE3cBByuEorBPFj1djfGs

Requested by dinwwwh · project thread
Zod
z.record(z.enum(['a', 'b']), V)andz.partialRecord, and Valibotv.record(v.picklist([...]), V), come out of oRPC as:{ "type": "object", "propertyNames": { "type": "string", "enum": ["a", "b"] }, "additionalProperties": V }(Zod adds
required.) Related shapes from the same libraries:z.literal('a'),v.literal('a')) writespropertyNames: { "type": "string", "const": "a" }..describe(),v.title/v.description, or a reusable enum's.meta({ title, description })put atitleordescriptionbeside theenum..meta({ id }), Pydanticdict[Enum, V]) writespropertyNames: { "$ref": "#/components/schemas/Lang" }, and Zod adds adescriptionbeside the$refwhen the use is described (z.record(Lang.describe('key'), V)).3.0 has no
propertyNames, so it was removed, generators typed the record asRecord<string, V>, and an enclosingoneOfbecameanyOfbecause the branch got looser.In 3.1 to 3.0, such a
propertyNamesnow becomes one property per name, each sharing the convertedadditionalPropertiesschema, plusadditionalProperties: false. That is exact, so an enclosingoneOfstays. A$refinto or through thatadditionalProperties(zod-to-json-schema root refs, swagger-parserbundle()) is replaced by its converted target, as$defsrefs are, since the schema it pointed at is nowfalse.It applies only when
propertyNamesholds just anenumor aconst(plus optionallytype: "string",title,description), or a$refto such a schema with at most atitleordescriptionbeside it, and noproperties,patternProperties,unevaluatedPropertiesoradditionalProperties: falsesits besidepropertyNames. Anything else keeps the current behavior (removed). A$refwith other keywords beside it (such asmaxLength) is not followed, since those narrow the names further, and anenumbeside aconstis left alone too.Two README limitations come with it:
z.union([z.literal('a'), z.literal('b')])) writes ananyOfofconstbranches; that is not handled..meta({ id })keeps each repeat to a$ref.Changes:
enumNameshelper and a matching line inlosesRestrictioninsrc/v3.1-to-v3.0.ts, plus six lines infinishSchema.ctxpasses throughloosenandlosesRestrictionsoenumNamescan follow a$refwith the existingresolve.isInlinedalso matches a$refthrough such a record'sadditionalProperties(isIntoNamedValue).constkeys,title/description, the$refshape with and without a description beside it, the cases where it is still removed, the enclosingoneOf, and a$refinto the value schema.Checked:
pnpm test(265 pass) andpnpm run checkclean. A doc generated by oRPC 2.0.0-beta.45 with Zod forz.literal,z.literal([...]), a describedz.enum, a.meta({ id, title, description })enum key, and a described use of it now gives typed properties in 3.0 and passes@seriousme/openapi-schema-validator; the literal union keeps the old output. On 3,000 random docs with enum-keyed records and$refs into, through, and around them (1,008 refs throughadditionalProperties), the output always serializes (no cycles) and no$refthat resolved in the input dangles; before theisInlinedchange, 147 of them dangled. Earlier checks: Ajv agrees with the 3.1 input for the enum-key variants, and 700 fuzzed oRPC Zod/Valibot docs stay valid 3.0 with no lost instances.🤖 Generated with Claude Code
https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY