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

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions