Rethinking (buf.validate.message).oneof: Replacing Sum Types with Product Types + Runtime Checks introduces Type Safety Regressions #4614
Replies: 2 comments
|
Additional context regarding ConnectRPC's official Rust, Swift & Kotlin support: I wanted to highlight one more point: ConnectRPC is no longer just for Go backend microservices. With official support now spanning Rust, Swift ( In all these non-Go target environments, the Protobuf Schema serves as the single source of truth for compile-time safety. Replacing native Since ConnectRPC actively promotes first-class support for languages with rich type systems like Rust, the toolchain should focus on improving Go's codegen ergonomics rather than watering down the universal Schema contract across the polyglot ecosystem. |
|
Hi @lwintermelon, thanks for reaching out! The Protovalidate’s You are free to use either (or both!) to implement |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Hi Buf Team & Community,
I’ve been following the discussions around Protovalidate and the recent proposal to replace standard Protobuf
oneofs with(buf.validate.message).oneofannotations. While I fully sympathize with the motivation—improving Go's ergonomics and working around Protobuf's historical limitations (likerepeated/mapinsideoneof)—I have strong reservations about this approach when viewed through the lens of a polyglot system.ConnectRPC's greatest strength is its ability to span diverse environments—from Web Frontends (TypeScript) and Mobile Apps (Swift on iOS), to High-Performance IPC/CLI or Services (Rust), all the way to Backend Services (Go). Protobuf's schema is the single source of truth for the type safety of this entire ecosystem.
From a type system perspective, replacing$A + B$ ) with a Product Type ($A \times B$ ) enforced solely via Runtime Validation.
oneofwith annotations isn't just an ergonomic refactoring; it fundamentally replaces a Sum Type (Here is why I believe this trade-off negatively impacts languages with native Sum Type support:
1. Compile-Time Guarantees vs. Runtime Validation
The primary value of a Sum Type is compile-time exhaustiveness and invalid-state unrepresentability.
oneof, generated code in languages like Rust or Swift maps directly to algebraic enums/discriminated unions. The compiler guarantees at build time that a message holds exactly one variant.(buf.validate.message).oneof, the generated structure becomes a plain struct where all fields are optional (a Product Type). This allows invalid states (e.g., all fields set, or no fields set) at the language level. Shifting state correctness from the compiler to runtime validation (CEL/Protovalidate) is a regression in type safety.2. Degradation of Pattern Matching & DX in Modern Languages
While
oneofcode generation in Go is admittedly verbose due to interface wrappers, it is incredibly clean and idiomatic in modern languages:Protobuf-ES): Nativeoneofgenerates clean Discriminated Unions:{ case: "success", value: SuccessData } | { case: "error", value: AppError } | { case: undefined, value: undefined }. Replacing it with flat fields forces clients into a plain interface with optional properties (success?: SuccessData; error?: AppError;), breaking TypeScript's control-flow type narrowing and exhaustiveswitchchecks.prost):match result { Some(VariantA(a)) => ..., Some(VariantB(b)) => ... }swift-protobuf):switch result { case .variantA(let a): ... }If we flatten
oneofinto plain struct fields, pattern matching breaks entirely. Developers are forced back into manualif-elsenull checks across every variant. If a new variant is added in a future API version, the compiler can no longer catch unhandled variants via exhaustiveness checks.3. Solving Go-Specific Pains at the Expense of Polyglot Ecosystems
It feels like this design choice prioritizes Go’s current type-system limitations over the safety features of richer type systems. Protobuf's strength lies in its cross-language neutrality. Degrading structural type safety to accommodate Go's verbose
oneofcodegen harms clients written in Rust, Swift, Kotlin, and TS.4. On Backwards/Forwards Compatibility &
oneofHandlingThe assertion that native
oneofs introduce unwieldy backwards-compatibility issues is largely overstated in modern type systems.When a server adds a new variant to a
oneof, legacy clients generated in modern client ecosystems smoothly handle unknown tags at the wire level by preserving them inunknownFields, while setting/resolving theoneofproperty safely at the generated message level:prost) and Swift (swift-protobuf), it maps cleanly toNone/nilon the message wrapper (e.g.,Option<MyOneofEnum>).Protobuf-ES), it resolves to{ case: undefined, value: undefined }.Legacy clients can easily inspect this undefined state (or map it to a domain-level
Unknownvariant) to gracefully fall back without runtime errors. Replacing nativeoneofwith flat optional fields doesn't magically solve forward compatibility—it merely hides structural evolution by turning everything into nullable fields, eroding compile-time exhaustiveness checks in the process.Alternative Suggestion
Instead of abandoning native
oneofin schema design:oneofs?(buf.validate.message).oneofas an opt-in fallback only when structural constraints (e.g., needingrepeatedormapfields in a variant) strictly prevent nativeoneofusage, rather than recommending it as a general replacement for Sum Types.I’d love to hear the team's thoughts on balancing Go ergonomics with compile-time type safety for Sum Type languages across the broader ConnectRPC community!
All reactions