Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,39 @@ message Event {
// Not flattened: {"id": "1", "type": "text", "text": {"body": "hello"}}
```

**Oneofs in TypeScript (default, un-annotated)** — A oneof with *no* `oneof_config`
annotation is not left as a bag of optional fields. Both TS generators render it as a
presence-discriminated union that mirrors protojson: exactly the set member's JSON key
appears on the parent object. Each arm carries one variant key with a non-optional
payload and types every sibling key `?: never`; a final all-`never` arm models the unset
oneof. A message with oneofs becomes the intersection of a base interface and one union
per oneof (independent oneofs each get their own union). The clients are conversion-free
(`JSON.stringify` on send, `as T` on receive), so the TS shape matches the wire exactly.

```proto
message PlainEvent {
string id = 1;
oneof content { // no oneof_config → presence union
TextContent text = 2;
ImageContent image = 3;
}
}
```
```typescript
export type PlainEventContent =
| { text: TextContent; image?: never }
| { image: ImageContent; text?: never }
| { text?: never; image?: never };
export interface PlainEventBase { id: string; }
export type PlainEvent = PlainEventBase & PlainEventContent;
```

Annotated oneofs differ: a non-flatten `oneof_config` keeps the discriminator and the
variant key flat on the parent (plus `?: never` sibling guards); `flatten: true` spreads
the variant's child fields onto the parent instead. See
[docs/client-generation.md](docs/client-generation.md#oneofs-in-typescript) for the full
TS shape and consumer caveats.

**flatten / flatten_prefix** - Promote nested message fields to parent (ext 50019, 50020):
```protobuf
message Order {
Expand Down
35 changes: 35 additions & 0 deletions docs/client-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,41 @@ try {

The proto definition serves as the single source of truth for error shapes — both server and client use the same generated interface for type safety across the wire.

### Oneofs in TypeScript

Both TypeScript generators render a protobuf `oneof` as a discriminated union that matches protojson exactly. The clients do no conversion — requests are `JSON.stringify`-ed and responses are cast with `as T` — so the generated types are the wire contract.

- **Un-annotated oneof (default)** — presence-discriminated union: exactly the set member's JSON key appears on the parent. Each arm carries one variant key with a non-optional payload and types every sibling `?: never`, with a final all-`never` arm for the unset oneof. A message becomes the intersection of a base interface and one union per oneof.
- **Annotated `oneof_config`, `flatten: false`** — the discriminator and the set variant's key both sit flat on the parent, with `?: never` guards on the sibling keys.
- **Annotated `oneof_config`, `flatten: true`** — the variant's child fields are spread onto the parent next to the discriminator; the variant key itself is not emitted.

```typescript
// message Event { string id = 1; oneof content { TextContent text = 2; int32 count = 3; } }
export type EventContent =
| { text: TextContent; count?: never }
| { count: number; text?: never }
| { text?: never; count?: never };
export type Event = EventBase & EventContent;

// Construct exactly one variant:
const e: Event = { id: "1", text: { body: "hi" } };

// Narrow on presence, NOT truthiness (count can legitimately be 0):
if ("text" in e && e.text !== undefined) {
console.log(e.text.body);
} else if ("count" in e && e.count !== undefined) {
console.log(e.count); // narrowed to number, correct even when 0
}
```

**Caveats:**

1. **Breaking change.** Consumers written against the old shapes break: the previous un-annotated "flattened bag" of optional fields, and the `event.content` wrapper property for `flatten: false`, are both gone. In particular, setting two members of the same oneof is now a compile error rather than a silently-tolerated object.
2. **Narrow scalar variants by presence, not truthiness.** Use `"count" in e` or `e.count !== undefined` — a truthiness check (`if (e.count)`) misfires on the zero values `0`, `""`, and `false`, which are valid payloads.
3. **Exactly-one is enforced only at object-literal construction.** That is where TypeScript rejects setting more than one member; the TS types are therefore stricter than the Python and OpenAPI outputs for the same proto.
4. **`?: never` is a compile-time guard, not runtime exclusivity.** Under non-strict TypeScript, `{ text: ..., image: undefined }` still type-checks, and reads go through an unchecked `as T` cast — nothing validates at runtime that exactly one member is set.
5. **The unset state is modeled differently across outputs.** TypeScript represents "no member set" with the all-`never` arm of the union, whereas OpenAPI represents it via `oneOf` / a discriminator — so the unset case is not equally visible when comparing the two generated surfaces.

## See Also

- **[HTTP Generation Guide](./http-generation.md)** - Go server-side handler generation
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This starts a working HTTP API with user management, authentication, and OpenAPI
| **[nested-resources](../../examples/nested-resources/)** | Organization hierarchy API | Deep path nesting (3 levels), multiple path params per endpoint |
| **[multi-service-api](../../examples/multi-service-api/)** | Multi-tenant platform | Multiple services, different auth levels, service/method headers |
| **[market-data-unwrap](../../examples/market-data-unwrap/)** | Financial market data API | Unwrap annotation for map values, JSON/protobuf compatibility |
| **[ts-client-demo](../../examples/ts-client-demo/)** | TypeScript client demo | TypeScript HTTP client, CRUD API, query params, headers, error handling |
| **[ts-client-demo](../../examples/ts-client-demo/)** | TypeScript client demo | TypeScript HTTP client, CRUD API, query params, headers, error handling, oneof presence-union |
| **[ts-fullstack-demo](../../examples/ts-fullstack-demo/)** | TypeScript full-stack demo | TS client + TS server from same proto, CRUD, unwrap, custom errors |

---
Expand Down Expand Up @@ -131,6 +131,7 @@ End-to-end TypeScript HTTP client demo with a NoteService CRUD API.
- Query parameters: filter by status, limit results
- Service-level headers (X-API-Key) and method-level headers (X-Request-ID)
- Structured error handling: `ValidationError` and `ApiError`
- Un-annotated oneof (`Note.reminder`) rendered as a TypeScript presence-discriminated union, narrowed by presence in the client (`describeReminder`)
- Go server implementing `NoteServiceServer` with in-memory store

```bash
Expand Down
20 changes: 20 additions & 0 deletions examples/ts-client-demo/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ import {
type NoteServiceClientOptions,
} from "./generated/proto/note_service_client.ts";

// ----------------------------------------------------------------------------
// Oneof helper: `Note.reminder` is an un-annotated proto oneof, so the client
// renders it as a presence-discriminated union — exactly one of `remindAt` /
// `remindInDays` is present per note, the other is typed `?: never`. Narrow it
// by PRESENCE (the `in` operator), never by truthiness: `remindInDays` of 0
// would be dropped by an `if (n.remindInDays)` check. TypeScript flows the
// branch so `n.remindAt` / `n.remindInDays` are non-never inside each guard.
// ----------------------------------------------------------------------------
function describeReminder(n: Note): string {
if ("remindAt" in n && n.remindAt !== undefined) {
return `reminder at ${n.remindAt}`;
}
if ("remindInDays" in n && n.remindInDays !== undefined) {
return `reminder in ${n.remindInDays} day(s)`;
}
return "no reminder";
}

// ============================================================================
// Section 1: Client Configuration
// ============================================================================
Expand Down Expand Up @@ -53,6 +71,8 @@ async function section2_crudOperations(client: NoteServiceClient) {
for (const n of allNotes.notes) {
const due = n.dueDate ? ` (due: ${n.dueDate})` : "";
console.log(` ${n.id}: "${n.title}" [${n.priority}, ${n.status}]${due}`);
// Un-annotated oneof narrowed by presence (see describeReminder above).
console.log(` ${describeReminder(n)}`);
if (n.tags?.length > 0) {
console.log(` tags: ${n.tags.map((t) => t.name).join(", ")}`);
}
Expand Down
4 changes: 4 additions & 0 deletions examples/ts-client-demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func (s *noteService) seedData() {
},
Metadata: map[string]string{"sprint": "12", "team": "platform"},
CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339),
// Un-annotated oneof: absolute-timestamp reminder variant.
Reminder: &pb.Note_RemindAt{RemindAt: now.Add(24 * time.Hour).Format(time.RFC3339)},
}
s.notes["note-2"] = &pb.Note{
Id: "note-2",
Expand All @@ -60,6 +62,8 @@ func (s *noteService) seedData() {
Metadata: map[string]string{"sprint": "12"},
DueDate: strPtr("2025-12-31"),
CreatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339),
// Un-annotated oneof: relative day-offset reminder variant.
Reminder: &pb.Note_RemindInDays{RemindInDays: 3},
}
s.notes["note-3"] = &pb.Note{
Id: "note-3",
Expand Down
11 changes: 11 additions & 0 deletions examples/ts-client-demo/proto/note_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ message Note {
map<string, string> metadata = 7;
optional string due_date = 8;
string created_at = 9;

// Un-annotated oneof: an optional reminder that is EITHER an absolute
// timestamp OR a relative day offset. With no oneof_config annotation, the
// TypeScript client renders this as a presence-discriminated union — exactly
// one member key is present, siblings are `?: never`. Narrow it by presence
// (the `in` operator), not truthiness. See "Oneofs in TypeScript" in
// docs/client-generation.md.
oneof reminder {
string remind_at = 10;
int32 remind_in_days = 11;
}
}

// --- Unwrap response: returns Note[] directly ---
Expand Down
31 changes: 30 additions & 1 deletion internal/annotations/oneof_discriminator.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func HasOneofDiscriminator(message *protogen.Message) bool {
// 1. Discriminator name collisions with parent message fields
// 2. When flatten=true: all variants must be message types
// 3. When flatten=true: variant child field names must not collide with parent fields or discriminator.
// 4. When flatten=false: discriminator must not equal any variant's JSON name.
func ValidateOneofDiscriminator(
message *protogen.Message,
oneof *protogen.Oneof,
Expand All @@ -148,7 +149,11 @@ func ValidateOneofDiscriminator(
return validateOneofFlatten(message, oneof, discriminator)
}

return nil
// Non-flatten path: the discriminator and each set variant's JSON key both sit
// flat on the parent object, so a discriminator equal to a variant key would
// emit a duplicate object key. The flatten path is exempt — it spreads the
// variant's child fields and never emits the variant key itself.
return validateDiscriminatorVariantCollision(message, oneof, discriminator)
}

// validateDiscriminatorNameCollision checks discriminator vs parent message fields.
Expand All @@ -174,6 +179,30 @@ func validateDiscriminatorNameCollision(
return nil
}

// validateDiscriminatorVariantCollision checks, on the non-flatten path, that the
// discriminator name does not equal any variant's JSON name. On that path the
// discriminator and the set variant's key share the parent object, so a
// discriminator matching a variant key would produce a duplicate TypeScript key
// (and collide with the sibling presence guards emitted for the other variants).
func validateDiscriminatorVariantCollision(
message *protogen.Message,
oneof *protogen.Oneof,
discriminator string,
) error {
for _, field := range oneof.Fields {
if field.Desc.JSONName() == discriminator {
return fmt.Errorf(
"oneof %s.%s (flatten=false): discriminator name %q collides with variant %q (JSON: %q); "+
"on the non-flatten path the discriminator and the variant key share the parent object",
message.Desc.Name(), oneof.Desc.Name(), discriminator,
field.Desc.Name(), field.Desc.JSONName(),
)
}
}

return nil
}

// validateOneofFlatten validates flatten-specific rules for a discriminated oneof.
func validateOneofFlatten(
message *protogen.Message,
Expand Down
166 changes: 166 additions & 0 deletions internal/annotations/oneof_discriminator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package annotations

import (
"strings"
"testing"

"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"

"github.com/SebastienMelki/sebuf/http"
)

// --- protogen harness -------------------------------------------------------
//
// ValidateOneofDiscriminator operates on real *protogen.Message / *protogen.Oneof
// values, which cannot be hand-mocked. These helpers compile a hand-built proto3
// FileDescriptorProto into a *protogen.Plugin so the validator can be exercised on
// genuine protogen inputs. The oneof_config is passed to the validator directly, so
// the descriptor needs no sebuf.http extension options.

const validateTestPkg = "test.validate.v1"

// validateJSONName mirrors protoc's default lowerCamelCase json_name derivation.
func validateJSONName(name string) string {
parts := strings.Split(name, "_")
for i, part := range parts {
if i > 0 && len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "")
}

// scalarField builds a singular proto3 string field descriptor.
func scalarField(name string, number int32) *descriptorpb.FieldDescriptorProto {
return &descriptorpb.FieldDescriptorProto{
Name: proto.String(name),
Number: proto.Int32(number),
Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(),
Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(),
JsonName: proto.String(validateJSONName(name)),
}
}

// oneofMsgField builds a singular message-typed field bound to the oneof at index 0.
func oneofMsgField(name string, number int32, msgName string) *descriptorpb.FieldDescriptorProto {
return &descriptorpb.FieldDescriptorProto{
Name: proto.String(name),
Number: proto.Int32(number),
Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(),
Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(),
TypeName: proto.String("." + validateTestPkg + "." + msgName),
JsonName: proto.String(validateJSONName(name)),
OneofIndex: proto.Int32(0),
}
}

// validateOneofFile builds a proto3 file with an Event message carrying a base
// field plus a two-variant oneof (text -> TextContent, image -> ImageContent).
func validateOneofFile() *descriptorpb.FileDescriptorProto {
textContent := &descriptorpb.DescriptorProto{
Name: proto.String("TextContent"),
Field: []*descriptorpb.FieldDescriptorProto{scalarField("body", 1)},
}
imageContent := &descriptorpb.DescriptorProto{
Name: proto.String("ImageContent"),
Field: []*descriptorpb.FieldDescriptorProto{scalarField("url", 1)},
}
event := &descriptorpb.DescriptorProto{
Name: proto.String("Event"),
Field: []*descriptorpb.FieldDescriptorProto{
scalarField("id", 1),
oneofMsgField("text", 2, "TextContent"),
oneofMsgField("image", 3, "ImageContent"),
},
OneofDecl: []*descriptorpb.OneofDescriptorProto{{Name: proto.String("content")}},
}

return &descriptorpb.FileDescriptorProto{
Name: proto.String("validate_oneof.proto"),
Package: proto.String(validateTestPkg),
Syntax: proto.String("proto3"),
Options: &descriptorpb.FileOptions{
GoPackage: proto.String("github.com/SebastienMelki/sebuf/internal/annotations/validatev1"),
},
MessageType: []*descriptorpb.DescriptorProto{textContent, imageContent, event},
}
}

func buildValidatePlugin(t *testing.T, fd *descriptorpb.FileDescriptorProto) *protogen.Plugin {
t.Helper()
req := &pluginpb.CodeGeneratorRequest{
FileToGenerate: []string{fd.GetName()},
ProtoFile: []*descriptorpb.FileDescriptorProto{fd},
}
plugin, err := protogen.Options{}.New(req)
if err != nil {
t.Fatalf("protogen.Options{}.New: %v", err)
}
return plugin
}

func findValidateMessage(t *testing.T, plugin *protogen.Plugin, name string) *protogen.Message {
t.Helper()
for _, file := range plugin.Files {
for _, msg := range file.Messages {
if string(msg.Desc.Name()) == name {
return msg
}
}
}
t.Fatalf("message %q not found in plugin", name)
return nil
}

func findValidateOneof(t *testing.T, msg *protogen.Message, name string) *protogen.Oneof {
t.Helper()
for _, oneof := range msg.Oneofs {
if string(oneof.Desc.Name()) == name {
return oneof
}
}
t.Fatalf("oneof %q not found on message %q", name, msg.Desc.Name())
return nil
}

// TestValidateOneofDiscriminator_VariantCollision covers the non-flatten
// discriminator/variant-key collision guard: on the non-flatten path the
// discriminator and the set variant's key share the parent object, so a
// discriminator equal to a variant's JSON name must be rejected. The flatten path
// is exempt because it spreads the variant's child fields and never emits the
// variant key itself.
func TestValidateOneofDiscriminator_VariantCollision(t *testing.T) {
plugin := buildValidatePlugin(t, validateOneofFile())
msg := findValidateMessage(t, plugin, "Event")
oneof := findValidateOneof(t, msg, "content")

t.Run("non_flatten_discriminator_equals_variant_name_errors", func(t *testing.T) {
config := &http.OneofConfig{Discriminator: "text", Flatten: false}
err := ValidateOneofDiscriminator(msg, oneof, config)
if err == nil {
t.Fatal("expected error for discriminator colliding with variant JSON name, got nil")
}
for _, want := range []string{"content", "text", "variant"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q missing expected substring %q", err.Error(), want)
}
}
})

t.Run("non_flatten_safe_discriminator_no_error", func(t *testing.T) {
config := &http.OneofConfig{Discriminator: "kind", Flatten: false}
if err := ValidateOneofDiscriminator(msg, oneof, config); err != nil {
t.Errorf("expected no error for safe discriminator, got: %v", err)
}
})

t.Run("flatten_discriminator_equals_variant_name_allowed", func(t *testing.T) {
config := &http.OneofConfig{Discriminator: "text", Flatten: true}
if err := ValidateOneofDiscriminator(msg, oneof, config); err != nil {
t.Errorf("expected no collision error on flatten path, got: %v", err)
}
})
}
Loading
Loading