Skip to content

feat(ts): emit discriminated-union oneofs in TS generators#199

Open
hishamank wants to merge 9 commits into
mainfrom
feat/ts-oneof-style
Open

feat(ts): emit discriminated-union oneofs in TS generators#199
hishamank wants to merge 9 commits into
mainfrom
feat/ts-oneof-style

Conversation

@hishamank

@hishamank hishamank commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What

Both TypeScript generators (protoc-gen-ts-client, protoc-gen-ts-server) now render oneofs that have no explicit sebuf.http oneof_config annotation as presence-discriminated flat unions:

export type PlainEventContent =
  | { text: TextContent; image?: never }
  | { image: ImageContent; text?: never }
  | { text?: never; image?: never };   // oneof unset

export interface PlainEventBase { id: string; }
export type PlainEvent = PlainEventBase & PlainEventContent;

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:

  • the old flattened bag carries no exactly-one guarantee and gives consumers nothing to narrow on;
  • a $case-style discriminated union (ts-proto's oneof=unions) describes JSON that never exists on the wire, and only works behind generated fromJSON/toJSON converters — which sebuf deliberately doesn't have.

A union discriminated by key presence, with ?: never sibling guards, is assignable to and from the exact protojson wire format while restoring the oneof guarantees:

  • narrowing works: if (e.text) { /* e.text: TextContent; e.image: undefined */ }
  • constructing an object with two members set is a compile error
  • the all-never arm models the unset oneof
  • set members are non-optional — oneof fields have explicit presence, so protojson always emits a set member, even at its zero value

Behavior

  • This is the default and only behavior — there is no configuration flag.
  • Synthetic oneofs (proto3 optional) are unaffected.
  • Oneofs with an explicit oneof_config annotation keep their annotated shape (the annotation always wins).
  • Messages whose fields all belong to oneofs skip the empty Base interface and alias the union directly.
  • Scalar members: presence-narrow with "count" in e or e.count !== undefined — truthiness misfires on 0/"".

Notes

  • Breaking change for consumers of the old flattened-bag shape that set multiple members at once (now a compile error) or relied on all members being independently optional.
  • Un-annotated non-synthetic oneofs route through the same intersection emission used by flattened annotated oneofs.

Tests

  • Golden fixtures regenerated; oneof_discriminator output shows presence unions, and the httpgen consistency test asserts $case never appears.
  • Cross-generator consistency tests (httpgen) updated.
  • Unit tests added in tscommon (TestSynthesizeOneofInfo, TestGeneratePresenceOneofUnionType, TestGenerateInterface_PresenceOneof), compiling a hand-built proto3 FileDescriptorProto into a protogen.Plugin to exercise the presence-union emitter and interface routing on real protogen inputs.
  • Full suite + staticcheck green.

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.62963% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 4.66%. Comparing base (b3f6487) to head (c84aec4).

Files with missing lines Patch % Lines
internal/tscommon/types.go 77.31% 21 Missing and 1 partial ⚠️
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     
Flag Coverage Δ
unittests 4.66% <79.62%> (+1.33%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🔍 CI Pipeline Status

Lint: success
Test: success
Coverage: success
Build: success
Integration: success


📊 Coverage Report: Available in checks above
🔗 Artifacts: Test results and coverage reports uploaded

@hishamank hishamank force-pushed the feat/ts-oneof-style branch from 216d06c to 80158f1 Compare July 3, 2026 10:27
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>
@hishamank hishamank force-pushed the feat/ts-oneof-style branch from 80158f1 to e30face Compare July 3, 2026 10:45
@hishamank hishamank changed the title feat(ts): oneof_style=discriminated option for TS generators feat(ts): emit discriminated-union oneofs in TS generators Jul 3, 2026
@hishamank hishamank marked this pull request as ready for review July 3, 2026 12:33
…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>
hishamank and others added 3 commits July 7, 2026 13:14
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>
@SebastienMelki

Copy link
Copy Markdown
Owner

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 JSON.stringify(req) / resp.json() as T, the types must match protojson exactly — and now they do.

Confirmed correct

  • Un-annotated oneof → plain protojson (set member's key flat on parent, no discriminator). No custom MarshalJSON is generated for these, and the Python client (independent Optional fields) and OpenAPI (flat optional properties, no oneOf) already emit the same shape — so this aligns TS with the rest of the toolkit rather than diverging.
  • Non-optional set members, the all-never unset arm, and the ?: never presence guards all correctly model protojson's explicit-presence behavior.
  • The flatten:false fix (dropping the phantom content?: Wrapper property) matches the generated MarshalJSONSebuf and also kills the snake_case-name leak. Nice regression test.

Findings

  1. (strong-recommend, pre-existing) Discriminator/variant-key collision is unvalidated for flatten:false annotated oneofs. validateDiscriminatorNameCollision (internal/annotations/oneof_discriminator.go:160-163) skips the oneof's own fields, and ValidateOneofDiscriminator only runs the flatten validator when flatten=true. So a non-flatten annotated oneof whose discriminator equals a variant's JSON name is accepted, and nonFlattenedOneofBranch emits a duplicate key ({ text: "text"; text: TextContent } → TS2300) while MarshalJSONSebuf overwrites the payload with the discriminator string. Note it collides with the sibling ?: never guards too (e.g. discriminator:"image" dupes image in every arm). This isn't a regression from this PR — the old branch had the same hazard — but since the PR already owns the oneof surface, it's a good place to add a check rejecting discriminator == any variant JSON name on the flatten:false path.

  2. (nit) Dead code. GenerateStandardInterface is now only ever called with zero discriminated oneofs; its oneof-handling branch and discriminatedOneofs param are unreachable and can be dropped.

  3. (nit) Internal inconsistency. The flattened-scalar branch still emits an optional payload (body?: string) while the non-flatten scalar path is now non-optional — same explicit-presence logic should apply to both.

  4. (tests) Coverage gaps — no fixture for (a) two un-annotated oneofs in one message (intersection of presence unions), (b) synthetic proto3 optional + real oneof in the same message, or (c) the flattened-scalar case. At least (a) is worth locking in.

Docs / caveats to add on this PR

No TS-specific oneof docs exist today (CLAUDE.md documents only annotated oneof_config; docs/client-generation.md has no oneof mention). Please add:

  • CLAUDE.md oneof section — the default un-annotated behavior + the TS presence-union shape.
  • docs/client-generation.md — an "Oneofs in TypeScript" subsection (mirror the Python docs' "Discriminated oneofs") carrying these caveats:
    1. Breaking change — old flattened-bag (un-annotated) and event.content wrapper (flatten:false) consumers break; setting two members is now a compile error.
    2. Scalar narrowing — use "count" in e / e.count !== undefined, not truthiness (misfires on 0 / "" / false).
    3. Exactly-one is enforced only at object-literal construction; TS is now stricter than the Python/OpenAPI outputs.
    4. ?: never is a compile-time guard, not runtime exclusivity (under non-strict TS, field: undefined is still assignable). Reads are an unchecked as T cast.
    5. Unset state is modeled via the all-never arm in TS but via oneOf/discriminator in OpenAPI — the unset story isn't equally visible across the two.
  • A TS example that uses a oneof — none of the TS examples currently do; add one so the shape is exercised end-to-end.

Thanks for the thorough tests (the FileDescriptorProtoprotogen.Plugin harness in tscommon is a nice touch).

hishamank and others added 4 commits July 9, 2026 14:51
…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>
@hishamank

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review — all addressed on this branch (c84aec4):

  1. Discriminator/variant collision — added validateDiscriminatorVariantCollision on the flatten:false path, rejecting a discriminator equal to any variant's JSON name (unit test covers the non-flatten error, the safe case, and flatten still being allowed). Implemented as a hard error since it only ever fired on input that already produced uncompilable TS. — d2a4aac
    2 & 3. Dead code — dropped GenerateStandardInterface's unused discriminatedOneofs param + field skip, and removed the unreachable flatten-scalar branch (scalar+flatten is rejected upstream by validateOneofFlatten); the scalar path is now always non-optional via nonFlattenedOneofBranch. — 5123052
  2. Tests — added a two_oneofs fixture (two independent un-annotated oneofs → TwoOneofs = TwoOneofsBase & TwoOneofsA & TwoOneofsB, each union guarding only its own siblings), asserted verbatim in both generators. — ad7f235

Docs — new "Oneofs in TypeScript" sections in CLAUDE.md and docs/client-generation.md covering the un-annotated / flatten:false / flatten:true shapes, a construct-and-narrow example, and all five caveats (breaking change incl. the two-member compile error; scalar narrowing by presence not truthiness; construction-time exactly-one; ?: never is compile-time-only + as T reads; unset via the all-never arm in TS vs oneOf in OpenAPI). — c84aec4

No existing goldens changed — only the two new two_oneofs fixtures were added.

@SebastienMelki

Copy link
Copy Markdown
Owner

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 examples/ TS demos (ts-client-demo, ts-fullstack-demo, rn-client-demo) actually exercise a oneof end-to-end — the only examples/ oneofs are still Go/Python (simple-api, python-encoding-demo). Would be nice to add an un-annotated oneof to one of the TS demo protos and a row in docs/examples/README.md so consumers see the presence-union shape in a real, buildable client. Optional/non-blocking — everything else is good to go from my side.

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