feat(ts): emit discriminated-union oneofs in TS generators#199
feat(ts): emit discriminated-union oneofs in TS generators#199hishamank wants to merge 9 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #199 +/- ##
========================================
+ Coverage 3.32% 4.66% +1.33%
========================================
Files 62 62
Lines 10966 11025 +59
========================================
+ Hits 365 514 +149
+ Misses 10595 10488 -107
- Partials 6 23 +17
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🔍 CI Pipeline Status✅ Lint: success 📊 Coverage Report: Available in checks above |
216d06c to
80158f1
Compare
Oneofs without an explicit sebuf.http oneof_config annotation now render as `$case` discriminated unions instead of flattened optional fields, in both TS generators (protoc-gen-ts-client, protoc-gen-ts-server). Synthetic oneofs (proto3 `optional`) are unaffected. This is the default behavior — there is no opt-in flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80158f1 to
e30face
Compare
…ions
The synthesized $case discriminated union described JSON that never exists
on the wire. Un-annotated oneofs serialize as plain protojson, which puts
the set member's JSON key directly on the parent object and emits no
discriminator field and no property named after the oneof. The generated
clients do raw JSON.stringify / resp.json() casts with no conversion layer,
so the $case shape was unreachable at runtime in both directions: reads
found undefined where the type promised a wrapper property, and writes sent
a "$case" key that protojson.Unmarshal rejects as an unknown field.
Un-annotated oneofs now render as a union discriminated by key presence,
intersected flat into the parent type — assignable to and from the exact
JSON the Go server produces and consumes, with no runtime conversion:
export type PlainEventContent =
| { text: TextContent; image?: never }
| { image: ImageContent; text?: never }
| { text?: never; image?: never };
export type PlainEvent = PlainEventBase & PlainEventContent;
The `?: never` guards keep the exactly-one construction guarantee and let
TypeScript narrow on presence (`if (e.text)`); the trailing all-never arm
models the unset oneof, which the server's protojson output permits. Set
oneof members are non-optional because oneof fields have explicit presence
— protojson always emits a set member, even at its zero value.
Messages whose fields all belong to oneofs skip the empty Base interface
and alias the union directly.
Annotated oneofs (oneof_config) are unchanged: their wire format is
produced by generated MarshalJSON/UnmarshalJSON pairs, so their TS shape
is a separate concern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add unit coverage for the un-annotated oneof presence-union emitter and interface routing in tscommon, compiling a hand-built proto3 FileDescriptorProto into a protogen.Plugin to exercise real protogen inputs: - synthesizeOneofInfo: empty Discriminator, one variant per field with JSON names, correct IsMessage for scalar vs message members. - generatePresenceOneofUnionType: set member non-optional plus ?: never sibling guards and a final all-never arm, across a 3-variant message oneof and a scalar+message oneof. - GenerateInterface: base field emits XBase + intersection alias; a message whose fields all belong to one oneof aliases the union directly with no XBase interface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#201) * fix(ts): flatten annotated non-flatten oneofs to match the wire format The TS generators rendered annotated non-flatten oneofs as a wrapper property named after the oneof: export interface NestedEvent { id: string; content?: { kind: "text"; text?: TextContent } | ...; } but no such property exists on the wire. The generated MarshalJSON writes the discriminator directly onto the parent object (raw["kind"] = "text") and leaves the variant key where protojson put it — also on the parent — so the actual JSON is {"id": ..., "kind": "text", "text": {...}}. The OpenAPI generator already documents this flat shape; TypeScript was the only generator disagreeing with the Go serialization. Non-flatten annotated oneofs now render flat, intersected with the base fields like flattened ones: export type NestedEventContent = | { kind: "text"; text: TextContent; image?: never; video?: never } | { kind: "image"; image: ImageContent; text?: never; video?: never } | { kind: "vid"; video: VideoContent; text?: never; image?: never } | { kind?: never; text?: never; image?: never; video?: never }; export type NestedEvent = NestedEventBase & NestedEventContent; Payloads are non-optional because oneof fields have explicit presence — a set member is always emitted. Sibling variant keys are `?: never` so narrowing on the discriminator also types the payload keys correctly, and the all-never arm models the unset oneof, which MarshalJSON emits with no discriminator at all. Flattened oneofs gain the same unset arm ({ type?: never }) for that reason. With every oneof now rendering flat on the parent, the wrapper-property branch in GenerateStandardInterface is unreachable and removed — which also removes the bug where the wrapper property kept the oneof's raw snake_case name (e.g. `super_title_image?:`) instead of a camelCase one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ts): cover multi-word oneof names in TS oneof emission Add a TS-only fixture (multi_word_oneof.proto) whose un-annotated oneof has a multi-word name (super_title_image), exercising the presence-union path in tscommon. This locks in the regression fixed on this branch: the oneof name must surface only as the PascalCase union type name (MultiWordEventSuperTitleImage) and never leak into the generated TS as a raw snake_case wrapper property. The fixture is scoped to the ts-client and ts-server generators only, leaving the shared oneof_discriminator.proto byte-identical across all six generators so the Go/OpenAPI/Python goldens and cross-generator consistency tests are untouched. - New golden test cases in tsclientgen/tsservergen; the new proto is added to the tsservergen cross-generator consistency proto list. - Explicit regression assertion (TestMultiWordOneofNameDoesNotLeak): generated TS contains MultiWordEventSuperTitleImage and not super_title_image. - Unit tests for nonFlattenedOneofBranch in tscommon (message, middle, and scalar variants), asserting discriminator/value/payload/never-guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
golangci-lint flagged the protogen test harness: msgFieldProto's signature and the oneofTestFile msg closure exceeded the 120-char golines limit, and oneofIndex's parameter always received 0 (unparam). Wrap the signatures and inline proto.Int32(0) at the call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review — approve in principle ✅ Read through the full diff and cross-checked the wire-shape reasoning against the other generators. The direction is right and this fixes two genuine pre-existing bugs where the TS types described JSON that never exists on the wire. Since the clients do conversion-free Confirmed correct
Findings
Docs / caveats to add on this PR No TS-specific oneof docs exist today (CLAUDE.md documents only annotated
Thanks for the thorough tests (the |
…eofs On the non-flatten annotated-oneof path the discriminator and the set variant's JSON key both sit flat on the parent object. A discriminator equal to a variant's JSON name therefore emitted a duplicate TypeScript key (and collided with the sibling `?: never` presence guards). ValidateOneofDiscriminator now rejects, on the flatten=false path only, a discriminator that matches any variant's JSON name. The flatten path is left unchanged: it spreads the variant's child fields and never emits the variant key, so a discriminator equal to a variant name is fine there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GenerateStandardInterface only runs for messages with zero discriminated oneofs (GenerateInterface routes any message that has them to GenerateFlattenedOneofInterface). Its discriminatedOneofs parameter, the BuildOneofFieldSet call, and the oneof-field skip were therefore dead; remove them and update the caller. Behavior is identical. In GenerateOneofDiscriminatedUnionType, the scalar default case branched on info.Flatten, but flatten+scalar is rejected upstream by validateOneofFlatten (flatten variants must be messages). The flatten sub-branch was unreachable and emitted an inconsistent optional payload; the scalar case now always renders via nonFlattenedOneofBranch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a two_oneofs fixture with a TwoOneofs message carrying two distinct un-annotated oneofs plus a base field, wired into both the ts-client and ts-server golden testCases. The message renders as the intersection of a base interface and both presence unions (TwoOneofs = TwoOneofsBase & TwoOneofsA & TwoOneofsB), each union carrying its own arms plus an all-never arm and guarding only its own siblings. TestTwoOneofsRenderAsIndependentPresenceUnions asserts the intersection alias, both unions, and that neither union references the other oneof's variant keys (the oneofs are independent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CLAUDE.md gains a section on the default un-annotated oneof behavior and the presence-union TS shape (previously only annotated oneof_config was documented). docs/client-generation.md gains an "Oneofs in TypeScript" subsection covering the un-annotated / flatten:false / flatten:true shapes, a worked construct-and-narrow example, and the five consumer caveats (breaking change, scalar narrowing by presence, construction-time exactly-one enforcement, compile-time-only ?: never guards, and the divergent unset modeling vs OpenAPI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — all addressed on this branch (
Docs — new "Oneofs in TypeScript" sections in No existing goldens changed — only the two new |
|
Follow-up review — thanks, all the substantive findings are addressed (collision validation, the dead-code/param cleanup, and the two-oneofs test all look great, and deleting the unreachable flatten+scalar branch is a sharper fix than I'd suggested). One item from the docs ask is still open: a runnable TS example that uses a oneof. The shape is now well covered in the docs snippets and locked into golden testdata, but none of the |
What
Both TypeScript generators (
protoc-gen-ts-client,protoc-gen-ts-server) now render oneofs that have no explicitsebuf.httponeof_configannotation as presence-discriminated flat unions:instead of a flattened bag of independent optional fields (
text?: ...; image?: ...).Why
Un-annotated oneofs serialize as plain protojson: the set member's JSON key appears directly on the parent object, and no discriminator field exists on the wire. The generated clients are conversion-free (
JSON.stringify(req)/resp.json() as T), so the TS types must describe exactly that wire shape — which rules out both alternatives:$case-style discriminated union (ts-proto'soneof=unions) describes JSON that never exists on the wire, and only works behind generatedfromJSON/toJSONconverters — which sebuf deliberately doesn't have.A union discriminated by key presence, with
?: neversibling guards, is assignable to and from the exact protojson wire format while restoring the oneof guarantees:if (e.text) { /* e.text: TextContent; e.image: undefined */ }Behavior
optional) are unaffected.oneof_configannotation keep their annotated shape (the annotation always wins).Baseinterface and alias the union directly."count" in eore.count !== undefined— truthiness misfires on0/"".Notes
Tests
oneof_discriminatoroutput shows presence unions, and the httpgen consistency test asserts$casenever appears.httpgen) updated.tscommon(TestSynthesizeOneofInfo,TestGeneratePresenceOneofUnionType,TestGenerateInterface_PresenceOneof), compiling a hand-built proto3FileDescriptorProtointo aprotogen.Pluginto exercise the presence-union emitter and interface routing on real protogen inputs.staticcheckgreen.