Skip to content

Support first-class default values for unset builder fields #227

Closed

Description

@AndreasIgel

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

  1. Constructor selection = the one with the most parameters.
    JavaLangAnalyser.findConstructorForBuilder picks the constructor annotated with @SimpleBuilderConstructor, otherwise the constructor with the highest parameter count. For a record that is always the canonical (all-components) constructor.
  2. Builder fields are generated from that constructor's parameters, and build() calls exactly that constructor. Unset fields are passed as null/0 (via TrackedValue.value()), e.g.:
    ProductRecord result = new ProductRecord(this.name.value(), this.price.value(), this.category.value());
  3. A smaller delegating record constructor that provides defaults is therefore never used — it has fewer parameters, so it is never selected:
    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"); }
    }
    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.
  4. Annotating the smaller constructor with @SimpleBuilderConstructor doesn'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.
  5. 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 null the caller passed;
    • it does not work for primitives (unset = 0 is indistinguishable from a deliberate 0);
    • it forces default logic into the type instead of being a builder concern.
  6. 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 receive null/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:

  • @Default on the component/field/parameter carrying a constant or a factory expression, applied in build() when the corresponding TrackedValue is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions