Skip to content

fix(ts): flatten annotated non-flatten oneofs to match the wire format#201

Merged
hishamank merged 3 commits into
feat/ts-oneof-stylefrom
fix/ts-annotated-oneof-flat-wire
Jul 7, 2026
Merged

fix(ts): flatten annotated non-flatten oneofs to match the wire format#201
hishamank merged 3 commits into
feat/ts-oneof-stylefrom
fix/ts-annotated-oneof-flat-wire

Conversation

@hishamank

Copy link
Copy Markdown
Collaborator

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 (oneof content, discriminator kind), the generated Go MarshalJSONSebuf writes 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 (oneOf variant schemas with text/image at the parent level). TypeScript was the only generator disagreeing with the Go serialization:

// before — describes JSON that never exists
export interface NestedEvent {
  id: string;
  content?: { kind: "text"; text?: TextContent } | ...;
}

Since the generated clients are raw-cast (JSON.stringify(req) / resp.json() as T, no conversion layer), event.content was always undefined at runtime, and requests built through the type were rejected by UnmarshalJSON (no kind at the top level).

After

Non-flatten annotated oneofs render flat, intersected with the base fields — same structure the flatten path already used:

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 interface NestedEventBase { id: string; }
export type NestedEvent = NestedEventBase & NestedEventContent;
  • Non-optional payloads: oneof fields have explicit presence — a set member is always emitted by protojson, so after narrowing on kind the payload is guaranteed.
  • ?: never sibling 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.
  • All-never unset arm: MarshalJSON omits the discriminator entirely when the oneof is unset (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 GenerateStandardInterface is 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 (GenerateOneofDiscriminatedUnionType dispatch, 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

  • Golden fixtures regenerated (oneof_discriminator client + server).
  • TestOneofDiscriminatorTypeScriptTypes extended to assert the flat NestedEvent shape: intersection alias, non-optional payload with never guards, and the unset arm.
  • Regenerated golden typechecks under tsc --strict.
  • Full suite + staticcheck green.

🤖 Generated with Claude Code

hishamank and others added 3 commits July 6, 2026 15:50
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
@hishamank
hishamank merged commit eee1e14 into feat/ts-oneof-style Jul 7, 2026
@hishamank
hishamank deleted the fix/ts-annotated-oneof-flat-wire branch July 7, 2026 09:37
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.

1 participant