fix(ts): flatten annotated non-flatten oneofs to match the wire format#201
Merged
Merged
Conversation
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>
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>
…-annotated-oneof-flat-wire # Conflicts: # internal/tscommon/types_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Fixes a pre-existing wire mismatch in the TS generators (
protoc-gen-ts-client,protoc-gen-ts-server) for annotated non-flatten oneofs: the TS types wrapped the union in a property named after the oneof, but that property never exists in the JSON.The mismatch
For
NestedEvent(oneofcontent, discriminatorkind), the generated GoMarshalJSONSebufwrites the discriminator directly onto the parent object (raw["kind"] = "text") and leaves the variant key where protojson put it — also on the parent:{ "id": "...", "kind": "text", "text": { "body": "..." } }The OpenAPI generator already documents exactly this flat shape (
oneOfvariant schemas withtext/imageat the parent level). TypeScript was the only generator disagreeing with the Go serialization:Since the generated clients are raw-cast (
JSON.stringify(req)/resp.json() as T, no conversion layer),event.contentwas alwaysundefinedat runtime, and requests built through the type were rejected byUnmarshalJSON(nokindat the top level).After
Non-flatten annotated oneofs render flat, intersected with the base fields — same structure the flatten path already used:
kindthe payload is guaranteed.?: neversibling guards: narrowing on the discriminator also types the other variant keys as absent, and constructing an object with two members set is a compile error.default:branch), so the unset case is representable. Flattened oneofs gain the same arm ({ type?: never }).With every oneof now rendering flat on the parent, the wrapper-property branch in
GenerateStandardInterfaceis unreachable and removed — which also removes the bug where multi-word oneof names leaked into TS as raw snake_case wrapper properties (e.g.super_title_image?:).Stacked on #199
Based on
feat/ts-oneof-style: it builds on the presence-union routing introduced there (GenerateOneofDiscriminatedUnionTypedispatch, intersection emission for oneof-bearing messages). The two changes restore the same invariant — the TS types describe exactly the JSON the Go marshaler produces — #199's follow-up commit for un-annotated oneofs, this PR for annotated ones.Tests
oneof_discriminatorclient + server).TestOneofDiscriminatorTypeScriptTypesextended to assert the flatNestedEventshape: intersection alias, non-optional payload with never guards, and the unset arm.tsc --strict.staticcheckgreen.🤖 Generated with Claude Code