Skip to content

Construction ergonomics for explicit-presence fields (edition 2023+) #30

@iainmcgin

Description

@iainmcgin

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): maps T::default() values to None (proto3 IMPLICIT semantics; can't distinguish unset from default-valued).
  • from_implicit_some(impl): maps every field to Some(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions