Skip to content

Instantiate structs via builders, not fieldwise #519

@adamchalmers

Description

@adamchalmers

Problem

Say a user has instantiated a ModelingCmd struct field-by-field (i.e. not using a constructor function, but rather, providing each field), like

let x = Export {
  entity_ids: Vec:new(),
  output_format: OutputFormat::Stl,
};

Problem is, adding a new field is not backwards-compatible. The above code will no longer compile if we add a third field, e.g. "compression: bool". Users will now get this compile error:

missing field `compression` in initializer of `Export`

Even if we add the field with #[serde(default)], this compile error will still happen. Serde can make JSON deserialization backwards-compatible (at runtime), but not instantiating the struct in Rust code (at compiletime).

Solution

One solution is to simply stop users from instantiating this way. There's a #[non_exhaustive] attribute we could put on a struct definition, which prevents users from instantiating it field-by-field. That forces users to use constructor methods. Currently we don't have any constructor methods, and defining our own constructor methods for each type would be a lot of work -- we have 60+ commands, so I don't want to handwrite a constructor for each. We have to generate them via a macro.

Luckily such a macro already exists -- the bon crate, which makes type-safe builders for structs. E.g.

use bon::Builder;
use serde::Deserialize;

#[derive(Builder, Deserialize)]
#[non_exhaustive]
struct Export {
    entity_ids: Vec<i32>,
    output_format: OutputFormat,
    #[serde(default)]
    #[builder(default)]
    compression: bool,
}

#[derive(Deserialize)]
enum OutputFormat {
    Stl,
    Fdx,
}

fn main() {
    let f = Export::builder()
        .entity_ids(Vec::new())
        .output_format(OutputFormat::Stl)
        .build();
}

In this example, the compression field is optional and will default to the default value of that field's type (i.e. false). So users don't have to call the .compression() method. But if they leave off a required method like .entity_ids() they'll get a compile error:

can't finish building yet; the member `ExportBuilder__output_format` was not set

This way, we can add new fields to the struct without breaking users -- we can default them to a certain value.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions