bon icon indicating copy to clipboard operation
bon copied to clipboard

Feature Request: method to fill the remaining values from an instance of existing built object

Open BernardIgiri opened this issue 6 months ago • 7 comments

I'd like to propose support for a .from(&T) method on bon builders that prepopulates the builder fields by cloning from an existing instance. This would streamline a common use case such as updating immutable database entities in web applications.

Use Case

This is especially useful in web servers: a struct is loaded from the database, a few fields are updated, and the new value is persisted. Currently, this requires repetitive field copying:

let updated = User::builder()
    .id(user.id.clone())
    .email(user.email.clone())
    .username("new_username".into())
    .build();

With .from(&user):

let updated = User::builder()
    .from(&user)
    .username("new_username".into())
    .build();

Proposal

  • Add .from(&T) to the builder, opt-in via #[builder(from_instance)].
    • #[builder(from_instance)] can ensure that Clone is derived as well.
  • Clone each field from the input value.
  • Skip fields marked #[builder(skip)].
  • Maintain bon's type-state guarantees.

Benefits

  • Reduces boilerplate for partial updates.
  • Aligns with real-world workflows (e.g., editing DB records).
  • Keeps compile-time safety and existing behavior intact.

Let me know if this is something you'd consider. I'd be happy to assist or prototype it.

Thank you for making bon, it has saved me so many hours of writing Rust boilerplate code!

BernardIgiri avatar Jun 26 '25 14:06 BernardIgiri

Hi! The problem with such a feature is that the .from(&T) method needs to know that it should omit cloning the username field. The fact that it doesn't set it is quite unobvious from the .from(&T) method name. Also, what if, say you call the from(&T) in several places, but in one place you want to update the username only, while in the second place you want to update email instead. In such case, you'll need two different methods without_username_from(&T) and without_email_from(&T). And what if in other place you want to update both username and email? Then you'll need without_username_and_email_from(&T). This approach doesn't scale well with all the possible combinations of fields users may want to update.

As an alternative to the suggested design, I see these options:

Clone the struct and mutate it directly or via setters:

let mut user  = user.clone()
user.username = "new_username".into();

// or if you'd like to hide the fields, then define setters
user.set_username("new_username".into());

// could also be a "consuming" setter:
let user = user.with_username("new_username".into());

There is an issue about adding support for derive(Setters, Getters) https://github.com/elastio/bon/issues/147 with references to existing implementations such as getset and derive_setters, although I see that you disliked that issue. If so, could you explain why?

Another approach would be to use the Rust's builtin struct update syntax:

let user = User {
    username: "new_username".into(),
    ..user
};

Btw. updating an object loaded from a DB may require a bit more logic than just assigning a value. For example, at work we keep track of whether any values actually changed with the updated_at field. We have a simple declarative macro that generates code like this inside of a setter:

if self.field != new_value {
    self.field = new_value;
    self.updated_at = now;
}

Anyway, let's first establish if the naive setters / field assignment / struct update syntax work for you.

Veetaha avatar Jun 26 '25 15:06 Veetaha

I really appreciate your detailed and thoughtful response. That said, I’m not sure we’re quite on the same page yet.

While I agree that setters are technically safe in Rust thanks to ownership and the borrow checker, I think there are broader concerns around intent and object integrity.

Setters modify fields one at a time and don’t provide any guardrails around overall validity, there’s no opportunity to enforce invariants until possibly much later, and mistakes can slip through. In contrast, builder-based construction enforces all constraints at the moment the object is built especially when using bon’s fallible builders feature. This is the whole reason why I use a builder in the first place, instead of just calling ::default() and using setters to construct my objects.

Here’s an example of the kind of pattern I’m using today with bon:

user.to_updated().email(new_email).first_name(new_first_name).call();

This lets me construct a modified version of user, while still validating that the result is a valid User. If bon supported something like .from(&user), I wouldn’t need to hand-roll to_updated(), I could get the same behavior, with less boilerplate, and still benefit from bon’s compile-time field enforcement and fallible builder support.

In this model:

  • .from(&T) clones all fields from an existing object into the builder (technically, the actually clone can be withheld until the final call to .build() or .call())
  • subsequent calls override fields as needed
  • .build() (or .call()) validates the result, just like any other use of bon

This preserves immutability, enables ergonomic partial updates, and maintains object integrity, all with a familiar builder-style interface.

Would love to hear your thoughts on whether that model could fit within bon’s philosophy.

BernardIgiri avatar Jun 26 '25 16:06 BernardIgiri

Setters modify fields one at a time and don’t provide any guardrails around overall validity

That makes sense to me.

Here’s an example of the kind of pattern I’m using today with bon:

 user.to_updated().email(new_email).call();

So this is what confuses me (or confused before I read your reply?) - how does the implementation of to_update() look like in your case?

I could imagine it be implemented in two different ways:

Either by delegating to the existing builder, which means the method returns the builder in fixed state where some fields are set and can not be overwritten (because bon prevents overwrites at compile time).

// The builder doesn't have to be generated via a `derive()` it could
// be generated via a #[builder] on a method - doesn't matter for
// the purposes of this example
#[derive(bon::Builder)]
struct User {
    id: String,
    username: String,
    email: String,
}

use user_builder::{SetEmail, SetId};
impl User {
    // We return a specific `UserBuilder` where both `email` and `id` are set
    // and thus they can no longer be overwitten
    fn to_updated(&self) -> UserBuilder<SetEmail<SetId>> {
        Self::builder()
            .id(self.id.clone())
            .email(self.email.clone())
    }
}

fn example(user: User) {
    let updated = user
        .to_updated()
        .username("new_username".to_string())
        .build();
}

This is why I was saying that this way you'd need without_username_from(&T), that would look almost exactly as this to_updated() method above. With this approach we need to select a specific field (or set of fields) that we want to omit from the builder.

However, the second approach, and I suppose this is the approach you are currently using is to define this to_updated() method with a #[builder] too - which essentially creates a second builder, that delegates to the main one:

use user_builder::{SetEmail, SetId};

#[derive(bon::Builder)]
struct User {
    id: String,
    username: String,
    email: String,
}

#[bon::bon]
impl User {
    #[builder]
    fn to_updated(
        &self,
        id: Option<String>,
        username: Option<String>,
        email: Option<String>,
    ) -> User {
        Self::builder()
            .id(id.unwrap_or_else(|| self.id.clone()))
            .email(email.unwrap_or_else(|| self.email.clone()))
            .username(username.unwrap_or_else(|| self.username.clone()))
            .build()
    }
}

fn example(user: User) {
    let updated = user
        .to_updated()
        .username("new_username".to_string())
        .call();
}

In this case - you'd define all inputs to the to_updated() method with Option<...> and lazily clone only the fields that are not updated, while also delegating to the main builder.

Is this how your to_updated() method's implementation looks right now?

Veetaha avatar Jun 26 '25 16:06 Veetaha

Yes your second example is exactly right!

BernardIgiri avatar Jun 26 '25 19:06 BernardIgiri

I see, the problem is more clear to me now. The approach of to_updated() method requires generating a separate builder, which is likely to double the cost of compilation times. Bon is already quite heavy on compile time overhead and such design would make it even worse.

Maybe we could use slightly different approach like this:

User::builder()
  .username("username".to_owned())
  .merge(user);

Here merge takes a User and returns the new User. We could also have merge_clone(&T) so it takes the value by reference and lazily clones only the fields that were not set.

This way we reuse the existing builder type - the only new thing we add is the merge/merge_clone methods to the builder.

The downside here is that this doesn't support nice method chaining syntax like user.to_updated()....

Note that I still don't know what terminology to use for this API. The term merge may not be intuitive enough to convey that the build process is finished (whereas build/call convey that very well). Also merge usually implies that values on "the left" are overwritten by the values on "the right" while in the syntax suggested above it's vice versa. However, Rust's struct update syntax also errs on this intuition by forcing the ..rest clause be at the end of struct literal.

We can bikeshed the name, but I guess this overall design of the finishing function accepting the object to merge from might be the golden mean. Note that this API will have natural limitations. Since one can place #[builder] macro on top of functions and since we have custom conversions via #[builder(with)] attribute, we won't always be able to derive the merge methods from the built object because we can't easily reverse the converted field value back to the builder's setter parameter type.

Other ideas for method name:

  • build_from/build_from_clone() (also not ideal, isn't obvious what "from" means?)

Another way to do this, that makes merge() naming more intuitive:

user.merge(User::builder().username("new_name".to_owned()))

One more problem with this approach: builder's that use members marked with start_fn/finish_fn, i.e. that accept positional parameters in their starting and finishing functions. Such merge API won't allow updating those. But maybe that's fine to have that limitation

Veetaha avatar Jun 26 '25 20:06 Veetaha

I really like your thinking here, and I appreciate the time you’ve taken to explore the trade-offs.

I think build_with or patch_with both work well. Including clone in the name (e.g. patch_with_clone or build_with_clone) could also help clarify the ownership behavior, but I trust your judgment to pick something that fits the overall API philosophy.

If there’s anything else I can contribute or test, I’m happy to help. Thanks again for taking on my request, I’m excited to see where this goes!

BernardIgiri avatar Jun 27 '25 22:06 BernardIgiri

Yeah, the naming is hard here. I think build_from sounds a bit better than build_with/patch_with, so let's settle with that for now (I'm almost sure we'll change the name anyway). But we still need to have some name for config attributes, which should be similar to #[builder(finish_fn)]. They should provide a way to configure functions names, visibility and docs. Having attributes #[builder(build_from, build_from_clone)] enables the additional methods.

If you'd like to work on this feature, I can provide some pointers for the implementation. The final implementation will be very much inspired by finish_fn.rs that is responsible for generating the current finishing function. We should also put this feature under some experimental-{feature_name} cargo feature flag, so we can freely make breaking changes to it until it becomes stable. You may see how that is done for #[builder(overwritable)] today.

I'll try to think about this more and provide some more details for the impl if you'd like a bit later. You can also contact me on Discord for any questions

Veetaha avatar Jun 27 '25 23:06 Veetaha