Repository navigation
Support first-class default values for unset builder fields #227
Description
Feature: first-class default values for unset builder fields
Summary
Simple Builders has no first-class default-value mechanism. When a builder field is not set, build() passes null (reference types) or the primitive zero-value to the target constructor — there is no way to declare "if this field is omitted, use value X." This especially bites records, which cannot give components inline defaults.
Why the current implementation does not solve this
- Constructor selection = the one with the most parameters.
JavaLangAnalyser.findConstructorForBuilderpicks the constructor annotated with@SimpleBuilderConstructor, otherwise the constructor with the highest parameter count. For a record that is always the canonical (all-components) constructor. - Builder fields are generated from that constructor's parameters, and
build()calls exactly that constructor. Unset fields are passed asnull/0(viaTrackedValue.value()), e.g.:ProductRecord result = new ProductRecord(this.name.value(), this.price.value(), this.category.value());
- A smaller delegating record constructor that provides defaults is therefore never used — it has fewer parameters, so it is never selected:
The user's intuition is correct: SB generates helper fields from the full parameter list and calls the full constructor, so the "smaller constructor with defaults" is bypassed.
public record Product(String name, double price, String category) { // intended to default category — but SB never calls this public Product(String name, double price) { this(name, price, "GENERAL"); } }
- Annotating the smaller constructor with
@SimpleBuilderConstructordoesn't help — then the builder only exposes those fewer parameters, so the remaining fields become unsettable. There is no configuration where all fields are settable and omitted ones fall back to a declared default. - Compact-constructor normalization only partially works and isn't a real default facility. SB does call the canonical constructor, so a compact constructor can null-coalesce a reference component (
if (category == null) category = "GENERAL";). But:- it cannot distinguish "unset" from an explicit
nullthe caller passed; - it does not work for primitives (unset
= 0is indistinguishable from a deliberate0); - it forces default logic into the type instead of being a builder concern.
- it cannot distinguish "unset" from an explicit
- For classes it is inconsistent. Field initializers act as defaults only for setter-injected fields (those the builder applies via
ifSet), because the selected max-arg constructor already consumes the others. Constructor-injected fields ignore field initializers (they receivenull/0). So "classes can use field initializers" is only sometimes true.
Net: TrackedValue already distinguishes unset from set, but nothing consumes "unset" to substitute a declared default — the unset value is passed straight to the constructor.
Proposed direction (to be designed)
Introduce a declared default that build() applies when a field is unset. Options to evaluate:
@Defaulton the component/field/parameter carrying a constant or a factory expression, applied inbuild()when the correspondingTrackedValueis unset (this.x.isSet() ? this.x.value() : <default>).- Default-provider method/static factory referenced by name.
- Detecting a secondary/smaller constructor as a defaults source — build via the canonical constructor but seed unset fields from the values the smaller constructor would supply (more magical; needs careful design).
Design constraints:
- Must work uniformly for records and classes, and for both constructor and setter fields.
- Must handle primitives (where "unset" is only known via
TrackedValue, not the value itself). - Should interact cleanly with required/non-null enforcement (a field with a default is not "required").
- Opt-in / non-breaking; no change to existing output unless a default is declared.
Acceptance criteria
- A documented way to declare a per-field default that is applied when the field is unset at
build(). - Works for records and classes, constructor and setter fields, including primitives.
- Interacts correctly with required/non-null checks.
- Tests covering: default applied when unset; explicit value overrides default; primitive defaults; record + class.
- Documentation (Usage) with examples, replacing the removed "default values" note.
Context
The misleading "default values" guidance was removed from the README in PR #226; this issue tracks actually implementing the capability.
Activity
Default values for builder fields via @default / @DefaultValue (#227) (…