Currently, there is a #[builder(on(pattern, attrs))] attribute that supports into configuration. With that one it's possible to enable #[builder(into)] for all members like this:
#[derive(bon::Builder)]
#[builder(on(_, into))]
struct Example {
// auto #[builder(into)]
x1: String,
// auto #[builder(into)]
x2: PathBuf,
// auto #[builder(into)]
x3: Box<str>,
// auto #[builder(into)]
x4: Box<Path>,
}
The _ is a pattern that matches all members. However, it's possible to match only specific types like this:
#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
// auto #[builder(into)]
x1: String,
x2: PathBuf,
x3: Box<str>,
x4: Box<Path>,
}
This way only x1 will get a #[builder(into)] because its type String matches the pattern. To match any member of Box type the following pattern could be used:
#[derive(bon::Builder)]
#[builder(on(Box<_>, into))]
struct Example {
x1: String,
x2: PathBuf,
// auto #[builder(into)]
x3: Box<str>,
// auto #[builder(into)]
x4: Box<Path>,
}
It's also possible to specify multiple on(...) clauses:
#[derive(bon::Builder)]
#[builder(
on(Box<_>, into),
on(String, into),
)]
struct Example {
// auto #[builder(into)]
x1: String,
x2: PathBuf,
// auto #[builder(into)]
x3: Box<str>,
// auto #[builder(into)]
x4: Box<Path>,
}
This syntax of #[builder(on(...))] works quite well for attributes that are boolean flags.
However, using it for attributes that can have more states than true/false is more complex. For example, #[builder(default)] is not just a boolean. It can accept any arbitrary expression: #[builder(default = 2 + 2)]. So then there is a question of how #[builder(on(...))] should behave when there are multiple on(...) directives that match the same member.
There are several ways to approach the problem.
Prioritization based on pattern specificity
We could obviously say that the pattern _ is more general than Box<_> or String. Therefore we could say that if there is on(String, default = ...) and #[builder(_, default)], then the on(String, default = ...) clause wins and thus its configuration is applied.
However, evaluating specificity is not as easy as that. Suppose, for example, there is this case:
#[derive(bon::Builder)]
#[builder(
on(BTreeMap<_, u32>, default = BTreeMap::from([(1, 1)])),
on(BTreeMap<u32, _>, default = BTreeMap::from([(2, 2)])),
)]
struct Example {
x1: BTreeMap<u32, u32>
}
What default config should be applied in this case? Both of the patterns BTreeMap<_, u32> and BTreeMap<u32, _> seem to be at the same level of specificity as for me. So... there is no one obvious way to resolve this configuration if we specialized based on pattern specificity.
So this approach doesn't work.
Prioritization based on relative ordering
We can say that on(...) directives behave like match arms in a match where the scrutinee is the member. I.e. it semantically works like this:
match member {
pattern1 => default = ...,
pattern2 => default = ...,
...
}
Then the answer for the problematic BTreeMap<_, u32>/BTreeMap<u32, _> case becomes obvious. The first pattern that we declared syntactically higher in the code wins. This requires the developer to manually arrange the on(...) directives in the order from the most specific to the least specific according to their taste.
However, this becomes inconvenient when multiple attributes are involved. For example:
#[derive(bon::Builder)]
#[builder(
on(String, into),
on(_, default),
)]
struct Example {
x1: String,
x2: PathBuf,
}
In this case the first on(String, into) will match the member x1. It specifies only into attribute, and thus x1 will get #[builder(into)], but it won't get #[builder(default)], which may or may not be the desired behavior.
Maybe instead, we could say that different into and default attributes are matched separately. So, for example, on(String, into) short circuits for into, but this directive is ignored for default because it doesn't mention it. If the user wants to explicitly disable default for Strings they should write on(String, into, reset(default)).
Extended pattern syntax and attributes that change the member's type and other matched properties
In version 3.0 of bon, a new attribute called #[builder(required)] was added. This attribute applies only to members of type Option<T>. It disables their special handling such that there is only one setter generated that accepts the Option<T> value directly, and that setter is required to call just like for any other member not marked with #[builder(default)].
There was a request for applying such an attribute at the top level with #[builder(on(...))] as well already (#35 (comment)). However, the problem is that this attribute changes the underlying type of the member. It changes it from T to Option<T>.
Currently, the type matching ignores the Option<...> wrapper. This allows the following to work:
#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
// auto #[builder(into)]
x1: String,
// auto #[builder(into)]
x2: Option<String>,
// auto #[builder(into)]
#[builder(default)]
x3: String,
}
All the members x1, x2, x3 have #[builder(into)] automatically configured for them. We didn't have to write on(String, into), on(Option<String>, into). The "underlying" type of the member is matched instead. What are the underlying types in this case? Here they are:
x1 - this is a required member and its underlying type is the type of the field itself i.e. String
x2 - this is an optional member and its underlying type is the type under the Option<...> i.e. String.
x3 - this is an optional member with a default and its underlying type is the type of the field itself i.e. String.
This way the "optional", "default", "required" behaviors are treated as separate parameters of the member. Member's underlying type is always independent of those parameters. This makes the match against the underlying type of the member stable. So if the user changes a required member of type String to Option<String> the match still occurs.
However, if we add #[builder(required)] into this, then the reasoning becomes more complex:
#[derive(bon::Builder)]
#[builder(on(String, into))]
struct Example {
// auto #[builder(into)]
x1: String,
#[builder(required)]
x2: Option<String>,
// auto #[builder(into)]
#[builder(default)]
x3: String,
}
Now, the member x2 becomes a required member with the underlying type of Option<String>. Therefore, the directive on(String, into) no longer matches it.
Then, if we support required in the on(...) directive itself, then the order in which we evaluate the on(...) directives becomes even more important. For example:
#[derive(bon::Builder)]
#[builder(
on(_, required),
on(String, into),
)]
struct Example {
// auto #[builder(into)]
x1: String,
x2: Option<String>,
// auto #[builder(into)]
#[builder(default)]
x3: String,
}
In this case, we assume that we first apply required to all members, and only after that do we evaluate the on(String, into). So after the first pass of applying required the x2 member's underlying type changes to Option<String>, and thus it no longer matches the on(String, into).
Then, we could say that if we change the order of on(...) directives here, things change like this:
#[derive(bon::Builder)]
#[builder(
on(String, into),
on(_, required),
)]
struct Example {
// auto #[builder(into)]
x1: String,
// auto #[builder(into, required)]
x2: Option<String>,
// auto #[builder(into)]
#[builder(default)]
x3: String,
}
The first pass applies into to all members with the underlying String type. The second pass applies required to the member x2.
Summing up
We need to generalize all of this into a consistent simple algorithm that could be explained and understood by the developers. Here is how it could be described:
-
All on(...) directives are evaluated in order of their declaration.
-
Once the first matching on(...) directive sets a config for a specific parameter of the member all other configs for this parameter in the remaining on(...) directives will be ignored. But... member-level configuration always takes precedence.
Example:
#[derive(bon::Builder)]
#[builder(
// use `true` as the default value for booleans
on(bool, default = true),
// for any other types use whatever the `Default` trait returns for them
on(_, default)
)]
struct Example {
// auto #[builder(default = true)]
// this is because the first matching `on(bool, ...)` directive
// specified the config for `default` first.
x1: bool,
// auto #[builder(default)]
x2: String,
// member-level config always wins
#[builder(default = "custom default".to_owned())]
x3: String,
}
-
The reset(...) directive can be used to explicitly request the "factory reset" for the config parameter so that on(...) directives below don't override its value. Example:
#[derive(bon::Builder)]
#[builder(
// explicit config for `u32` that asks to use the "factory settings" for `default`,
// which is that it doesn't have a default value (the member is required)
on(u32, reset(default)),
//
on(_, default)
)]
struct Example {
// non-default required member
// this is because the first matching `on(u32, ...)` directive
// specified the "reset" config for `default` first
x1: u32
// Member-level config always wins
#[builder(default)]
x2: u32,
// "Factory-reset" the `default` config for this specific member.
// So this member will be required (as it is when no config is applied)
#[builder(reset(default))]
x3: String,
}
-
The on(...) directives that are declared earlier (higher in code) change the properties of the member and these changes influence the matching for the on(...) directives that come later (lower in code).
Example:
#[derive(bon::Builder)]
#[builder(
on(_, required),
on(String, into),
)]
struct Example {
// auto #[builder(into)]
x1: String,
// Doesn't get `#[builder(into)]` because `on(_, required)`
// changed the underlying type of this member from `String` to `Option<String>`,
// so `on(String, into)` no longer matches it.
x2: Option<String>,
// auto #[builder(into)]
#[builder(default)]
x3: String,
}
Future posibilities
In the future, there can be more complex syntax for patterns in on(pattern, attrs). We could allow selecting members not only by their type, but also by other properites:
on(prefix = foo, ...) select members with names that start with foo
on(required(), ...) select all reequired members
on(has(into), ...) select members that have #[buider(into)] applied to them
A note for the community from the maintainers
Please vote on this issue by adding a 👍 reaction to help the maintainers with prioritizing it. You may add a comment describing your real use case related to this issue for us to better understand the problem domain.
Currently, there is a
#[builder(on(pattern, attrs))]attribute that supportsintoconfiguration. With that one it's possible to enable#[builder(into)]for all members like this:The
_is a pattern that matches all members. However, it's possible to match only specific types like this:This way only
x1will get a#[builder(into)]because its typeStringmatches the pattern. To match any member ofBoxtype the following pattern could be used:It's also possible to specify multiple
on(...)clauses:This syntax of
#[builder(on(...))]works quite well for attributes that are boolean flags.However, using it for attributes that can have more states than
true/falseis more complex. For example,#[builder(default)]is not just a boolean. It can accept any arbitrary expression:#[builder(default = 2 + 2)]. So then there is a question of how#[builder(on(...))]should behave when there are multipleon(...)directives that match the same member.There are several ways to approach the problem.
Prioritization based on pattern specificity
We could obviously say that the pattern
_is more general thanBox<_>orString. Therefore we could say that if there ison(String, default = ...)and#[builder(_, default)], then theon(String, default = ...)clause wins and thus its configuration is applied.However, evaluating specificity is not as easy as that. Suppose, for example, there is this case:
What
defaultconfig should be applied in this case? Both of the patternsBTreeMap<_, u32>andBTreeMap<u32, _>seem to be at the same level of specificity as for me. So... there is no one obvious way to resolve this configuration if we specialized based on pattern specificity.So this approach doesn't work.
Prioritization based on relative ordering
We can say that
on(...)directives behave like match arms in amatchwhere the scrutinee is the member. I.e. it semantically works like this:Then the answer for the problematic
BTreeMap<_, u32>/BTreeMap<u32, _>case becomes obvious. The first pattern that we declared syntactically higher in the code wins. This requires the developer to manually arrange theon(...)directives in the order from the most specific to the least specific according to their taste.However, this becomes inconvenient when multiple attributes are involved. For example:
In this case the first
on(String, into)will match the memberx1. It specifies onlyintoattribute, and thusx1will get#[builder(into)], but it won't get#[builder(default)], which may or may not be the desired behavior.Maybe instead, we could say that different
intoanddefaultattributes are matched separately. So, for example,on(String, into)short circuits forinto, but this directive is ignored fordefaultbecause it doesn't mention it. If the user wants to explicitly disabledefaultforStrings they should writeon(String, into, reset(default)).Extended pattern syntax and attributes that change the member's type and other matched properties
In version
3.0of bon, a new attribute called#[builder(required)]was added. This attribute applies only to members of typeOption<T>. It disables their special handling such that there is only one setter generated that accepts theOption<T>value directly, and that setter is required to call just like for any other member not marked with#[builder(default)].There was a request for applying such an attribute at the top level with
#[builder(on(...))]as well already (#35 (comment)). However, the problem is that this attribute changes the underlying type of the member. It changes it fromTtoOption<T>.Currently, the type matching ignores the
Option<...>wrapper. This allows the following to work:All the members
x1,x2,x3have#[builder(into)]automatically configured for them. We didn't have to writeon(String, into), on(Option<String>, into). The "underlying" type of the member is matched instead. What are the underlying types in this case? Here they are:x1- this is a required member and its underlying type is the type of the field itself i.e.Stringx2- this is an optional member and its underlying type is the type under theOption<...>i.e.String.x3- this is an optional member with adefaultand its underlying type is the type of the field itself i.e.String.This way the "optional", "default", "required" behaviors are treated as separate parameters of the member. Member's underlying type is always independent of those parameters. This makes the match against the underlying type of the member stable. So if the user changes a required member of type
StringtoOption<String>the match still occurs.However, if we add
#[builder(required)]into this, then the reasoning becomes more complex:Now, the member
x2becomes a required member with the underlying type ofOption<String>. Therefore, the directiveon(String, into)no longer matches it.Then, if we support
requiredin theon(...)directive itself, then the order in which we evaluate theon(...)directives becomes even more important. For example:In this case, we assume that we first apply
requiredto all members, and only after that do we evaluate theon(String, into). So after the first pass of applyingrequiredthex2member's underlying type changes toOption<String>, and thus it no longer matches theon(String, into).Then, we could say that if we change the order of
on(...)directives here, things change like this:The first pass applies
intoto all members with the underlyingStringtype. The second pass appliesrequiredto the memberx2.Summing up
We need to generalize all of this into a consistent simple algorithm that could be explained and understood by the developers. Here is how it could be described:
All
on(...)directives are evaluated in order of their declaration.Once the first matching
on(...)directive sets a config for a specific parameter of the member all other configs for this parameter in the remainingon(...)directives will be ignored. But... member-level configuration always takes precedence.Example:
The
reset(...)directive can be used to explicitly request the "factory reset" for the config parameter so thaton(...)directives below don't override its value. Example:The
on(...)directives that are declared earlier (higher in code) change the properties of the member and these changes influence the matching for theon(...)directives that come later (lower in code).Example:
Future posibilities
In the future, there can be more complex syntax for patterns in
on(pattern, attrs). We could allow selecting members not only by their type, but also by other properites:on(prefix = foo, ...)select members with names that start withfooon(required(), ...)select all reequired memberson(has(into), ...)select members that have#[buider(into)]applied to themA note for the community from the maintainers
Please vote on this issue by adding a 👍 reaction to help the maintainers with prioritizing it. You may add a comment describing your real use case related to this issue for us to better understand the problem domain.