-
Notifications
You must be signed in to change notification settings - Fork 25
Construction ergonomics for explicit-presence fields (edition 2023+) #30
Description
With editions 2023+ (and proto3 optional fields), generated Rust types wrap scalar/string fields in Option<T>. This makes struct-literal construction verbose:
let req = GetSecretRequest {
name: Some("alice".into()),
timeout_ms: Some(30_000),
enabled: Some(true),
..Default::default()
};Most callers writing protobuf messages from Rust source want proto3-style ergonomics (just write the values, the codegen handles wrapping). The current shape forces Some(...).into() ceremony at every field, and "alice".into() doesn't work because there's no From<&str> for Option<String> (Rust orphan rules make adding one impossible).
Cross-language survey
Most languages sidestep this by not using struct-literal construction:
| Language | Construction style | How wrapping is hidden |
|---|---|---|
| Go | pb.X{Name: proto.String("alice")} |
proto.String(s string) *string helper functions |
| Java | X.newBuilder().setName("alice").build() |
Builder pattern |
| Python | X(name="alice") |
Dynamic typing; HasField() separate query |
| C++ | req.set_name("alice") |
Mutating setters |
| C# | req.Name = "alice" |
Native nullable types |
| Kotlin | X { name = "alice" } |
DSL builder lambda |
| TypeScript (protobuf-es) | create(XSchema, { name: "alice" }) |
Structural typing + optional fields |
| Swift | req.name = "alice" |
Native optional syntax |
Of these, only Go has comparable syntactic friction, and it standardized on proto.String(...) helpers. Rust's combination of struct-init idiom + verbose Option<T> syntax + no implicit address-of operator is uniquely awkward.
Proposed approaches
These are not mutually exclusive - we'll likely implement multiple options behind codegen flags and let users pick.
1. Generated with_* setter methods
let req = GetSecretRequest::default()
.with_name("alice")
.with_timeout_ms(30_000)
.with_enabled(true);Each field gets a with_<name>(impl Into<T>) -> Self method that handles the Some wrapping internally. Smallest codegen footprint, composes mid-pipeline, familiar from tonic/prost ecosystem. Loses struct-init's "all fields named here at once" property.
2. Implicit shadow struct + from_implicit constructor
Generate a parallel struct with proto3-style bare types:
let req = GetSecretRequest::from_implicit_none(GetSecretRequestImplicit {
name: "alice".into(),
timeout_ms: 30_000,
enabled: true,
..Default::default()
});The shadow struct mirrors the message with implicit-presence semantics (bare T instead of Option<T>). Two converter variants make the presence semantic explicit at the call site:
from_implicit_none(impl): mapsT::default()values toNone(proto3 IMPLICIT semantics; can't distinguish unset from default-valued).from_implicit_some(impl): maps every field toSome(value)(lossless; preserves "explicitly sent default" distinction).
Closest precedent: derive_builder's FooBuilder parallel-struct pattern, applied to presence semantics rather than construction validation. Doubles the message type count and adds compile time, but gives proto3 muscle memory back via struct-init.
3. Builder type
let req = GetSecretRequest::builder().name("alice").timeout_ms(30_000).build();Method chaining with explicit build() step. Heavier than with_* setters; supports validation hooks; widely understood pattern.
4. set! macro
let req = set!(GetSecretRequest { name: "alice", timeout_ms: 30_000 });Macro expands struct-literal-like syntax into Some(...) wrapping based on field types. Most compact at the call site; introduces a non-obvious macro that needs to be learned and maintained.
Recommendation
Implement multiple options behind a codegen flag (e.g. construction_style = builder | setters | implicit_shadow | macro | all) and gather feedback on which one(s) users actually reach for. The with_* setters and implicit shadow would be the highest-leverage pair: setters for mid-pipeline construction, shadow struct for struct-init muscle memory.