Accumulating Assembly
Building a record from N validated fields: every error reported at once.
- Assembling a record from independently validated fields with
Validated.fields()andValidated.accumulate(): every error reported at once, noSemigroupargument, no arity wall, noKind - How
field(label, value)attaches a path to each error, and how nesting composes paths (address.zip) - The guarantee that errors emerge in field-declaration order
- The same assembly shape on all three carriers:
Validated(strict),ValidationPath(railway), andEitherOrBoth(tolerant, value keeps flowing) - When to reach for the builder versus
zipWithAccumor themapNfamily - Guarding a constructor that may refuse the fields, with
construct
The code on this page is ValidatedAssemblyBook.java - the page includes it directly, so it is compiled and run by the build.
The Problem: Assembling N Fields Was the Hard Part
Validated accumulates errors, and NonEmptyList gives the errors an honest carrier. But the everyday task (a request DTO becomes a domain aggregate; a raw config becomes a settings object) is assembling a record from N validated fields, and until now that meant choosing between:
// The HKT-generic form: capped at map5, and every call passes the Semigroup by hand.
MonadError<ValidatedKind.Witness<List<String>>, List<String>> validatedMonad =
Instances.validated(Semigroups.list());
Kind<ValidatedKind.Witness<List<String>>, User> user =
validatedMonad.map3(nameKind, emailKind, ageKind, User::new);
- An arity wall.
map2..map5stop at five fields;zipWithAccumis binary andzipWith3Accumternary. - Ceremony. The
Kindwrappers and the explicitSemigroupargument surface exactly when you want the least friction. - Unlocated errors. An accumulated failure is a flat list; nothing says which field each error belongs to.
The staged assembly builder removes all three at once.
The Front Door: fields()
Validated.fields() opens a labelled assembly over NonEmptyList<FieldError>. Each field(label, value) adds one validated field; apply(...) completes the assembly with a constructor reference or lambda of exactly the accumulated arity:
Validated<NonEmptyList<FieldError>, User> assembled =
Validated.fields()
.field("name", parseName(dto.name())) // Validated<NonEmptyList<FieldError>, Name>
.field("email", parseEmail(dto.email()))
.field("age", parseAge(dto.age()))
.apply(User::new); // (Name, Email, Age) -> User
// Invalid(NonEmptyList[email: not an email address, age: not a number]) - name was fine.
Three guarantees, all by construction:
- Every bad field is reported at once. Nothing short-circuits; accumulation is
NonEmptyListconcatenation. - Errors are located.
field("email", ...)prepends"email"onto each error's path viaFieldError.at, so consumers can render"email: not an email address"or map errors onto form fields. - Errors emerge in field-declaration order. The order of
field(...)calls is the order of the errors, which makes downstream output (an HTTP 422 body, a CLI report) deterministic.
A leaf validator creates unlabelled errors with FieldError.of("message"); the assembly attaches the location. FieldError is a small record (path segments plus a message) with a pathString() such as "address.zip".
When the Record Refuses: construct
A record often guards itself: a compact constructor that throws when its fields disagree. apply runs whatever function you hand it, so that exception escapes the assembly, taking with it whatever an enclosing assembly had collected around it, and, on the tolerant carrier, the warnings this one had. construct completes the assembly the same way but guards the call: once every field is valid, a RuntimeException the function throws becomes an unlabelled FieldError carrying the exception's message, or the fallback you pass when the message is missing or blank:
// The record guards itself: a window must close after it opens.
record Window(LocalDate opens, LocalDate closes) {
Window {
if (!closes.isAfter(opens)) {
throw new IllegalArgumentException("closes must be after opens");
}
}
}
Validated<NonEmptyList<FieldError>, Window> window =
Validated.fields()
.field("opens", parseDate("2026-03-09"))
.field("closes", parseDate("2026-03-07"))
.construct(Window::new, "not a valid Window"); // apply(Window::new) would throw
// Invalid(NonEmptyList[closes must be after opens]) - unlabelled: the whole window is refused
The rules are the generated mappings' own (a record's own invariants):
- The function runs last. It needs every field, so it runs only once all of them are valid, and a record reports either its fields' errors or its refusal, never both.
- The refusal is unlabelled, so the
field(label, ...)that nests the assembly locates it:window: closes must be after opens. - Any
RuntimeExceptioncounts, so a bug in the function reaches the caller as its message, with its stack trace dropped. Keep the function to checks on its arguments, with messages written for whoever reads the errors. - Only the function is guarded. A
nullit returns still throws, as it does fromapply; only what the function itself throws is a refusal. - Every labelled builder has it:
Validated.fields(),Path.fields(), andEitherOrBoth.fields(). On the tolerant carrier a failed field still short-circuits the function, while aBothfield's warning does not, so a refusal there comes back as aLeftcarrying those warnings and the refusal. The genericaccumulate()builders have noFieldErrorto build, so they end inapplyonly.
Nesting Composes
Because at() prepends, assembling a sub-record under an outer label prefixes all its inner paths:
Validated<NonEmptyList<FieldError>, Address> address =
Validated.fields()
.field("street", parseStreet(raw.street()))
.field("zip", parseZip(raw.zip())) // fails: "zip: not a postcode"
.apply(Address::new);
Validated<NonEmptyList<FieldError>, Customer> customer =
Validated.fields()
.field("name", parseName(raw.name()))
.field("address", address) // prefixes: "address.zip: not a postcode"
.apply(Customer::new);
This also doubles as the escape hatch for very wide records: apply overloads exist up to arity 16 (matching the shipped Function3..Function16); a record with more fields nests a sub-record per group, which usually improves the domain model anyway.
The Generic Flavour: accumulate()
When your error type is not FieldError, accumulate() gives the same open-arity assembly for any payload X, carried as NonEmptyList<X>. Fields join with and(value):
Validated<NonEmptyList<ConfigError>, Settings> settings =
Validated.accumulate()
.and(parseHost(rawSettings.host()))
.and(parsePort(rawSettings.port()))
.apply(Settings::new);
There is still no Semigroup argument: the carrier is fixed to NonEmptyList, and accumulation is concatenation.
- Inline literals need a type witness. A chained stage receives no target typing, so an inline factory call such as
.field("age", Validated.invalidNel(FieldError.of("bad")))infersObjectand the compile error surfaces later, atapply. WriteValidated.<FieldError, Integer>invalidNel(...)for inline literals; values with declared types (the results of leaf validators) need no witness. - Error payloads are invariant. Leaves typed with a subtype of a sealed error hierarchy (
Validated<NonEmptyList<PortError>, A>) do not widen automatically to the assembly's payload (ConfigError). Widen at the leaf:parsePort(raw).mapError(nel -> nel.map(e -> (ConfigError) e)), or have leaf validators return the hierarchy's root type directly.
One Shape, Three Carriers
The same two entry points exist on each carrier, so the assembly reads identically whichever error strategy the surrounding code uses:
| Carrier | Entries | Result | Behaviour |
|---|---|---|---|
Validated | Validated.fields() / Validated.accumulate() | Validated<NonEmptyList<E>, R> | strict: any invalid field fails the assembly, all errors kept |
ValidationPath | Path.fields() / Path.accumulate() | ValidationPath<NonEmptyList<E>, R> | the railway flavour; composes onward with via, zipWithAccum, recover |
EitherOrBoth | EitherOrBoth.fields() / EitherOrBoth.accumulate() | EitherOrBoth<NonEmptyList<E>, R> | tolerant: warnings accumulate (Both) while the value keeps flowing; any Left dominates, still keeping every warning |
The tolerant flavour is the natural fit for lenient config parsing:
EitherOrBoth<NonEmptyList<String>, Config> cfg =
EitherOrBoth.accumulate()
.and(parsePortLenient(rawConfig.port())) // Both(NonEmptyList[port defaulted], 8080)
.and(parseTimeoutLenient(rawConfig.timeout())) // Right(30)
.apply(Config::new);
// Both(NonEmptyList[port defaulted], Config[port=8080, timeout=30])
Under the hood every stage transition delegates to the existing accumulation primitives: Validated.ap with NonEmptyList.semigroup(), ValidationPath.zipWithAccum, and EitherOrBoth.zipWithAccum. The builder introduces no second accumulation mechanism.
Generating the Companion: @GenerateAssembly
For records you own, the annotation processor generates a per-record companion that removes the three remaining failure modes of the hand-written chain: labels come from the component names (typo-proof, rename-safe), field order cannot be wrong (named, order-enforcing stage methods), and there is no arity ceiling (the generator emits exact arity, even past 16).
@GenerateAssembly
record User(Name name, Email email, Age age) {}
Validated<NonEmptyList<FieldError>, User> generated =
UserAssembly.fields()
.name(parseName(dto.name())) // label "name" attached automatically
.email(parseEmail(dto.email()))
.age(parseAge(dto.age()))
.assemble(); // canonical constructor baked in
A component whose type is itself annotated accepts its sub-companion's result directly, and the outer component name prefixes the inner paths (address.zip). The companion lives in the record's package, named <Record>Assembly; for a nested record the enclosing simple names are joined (Outer.Inner gives OuterInnerAssembly). Under the hood the companion merges through the same Validated.ap / NonEmptyList.semigroup() primitives as the builder, so the two agree on every input the record's constructor accepts. Where the constructor throws, they part: the builder's apply runs whatever function you hand it, so the exception propagates, while assemble() reports it as an unlabelled FieldError carrying the exception's message, the same invariant guard the generated mappings use. End the builder with construct instead of apply and the two agree again. Generic records are not supported; use the hand-written fields() builder for records you cannot annotate. The companion names the record and each component's type from the record's package, so the processor refuses one that is private, or nested in a private class, naming the class to change (Compiler Messages).
Choosing the Right Tool
| You are combining... | Reach for |
|---|---|
| N independent fields into a record | fields() / accumulate() |
N validated fields into an existing value (sparse update / PATCH) | Edits.accumulate |
| A record you own, assembled in many places | @GenerateAssembly |
| Two or three values, inline, no labels needed | zipWithAccum / zipWith3Accum on the Path types, or EitherOrBoth.zipWithAccum |
Values inside generic Kind-polymorphic code | the Applicative mapN family |
| Steps where later ones depend on earlier results | flatMap / via (short-circuiting, by design) |
hkj-test asserts a whole accumulation as rendered "path: message" lines with assertThatValidated(result).hasFieldErrors(...), and takes a single error apart with assertThatFieldError:
assertThatFieldError(FieldError.of("not a postcode").at("zip").at("address"))
.hasPath("address.zip")
.hasSegments("address", "zip")
.hasMessageContaining("postcode");
fields()assembles a record from N validated fields with located errors;accumulate()is the generic-payload twin.- No
Semigroupargument, noKind, no arity wall up to 16; wider records nest sub-records. - Errors always emerge in field-declaration order.
- Nesting prepends the outer label:
"address.zip". construct(Record::new, "not a valid Record")guards a constructor that may refuse the fields;applydoes not.- The same shape exists on
Validated,ValidationPath(viaPath), andEitherOrBoth(tolerant).
- Validated: the underlying strict accumulating type
- NonEmptyList: the error carrier the builder is fixed to
- EitherOrBoth: the tolerant carrier
- ValidationPath: the railway validation API
- Validated Prisms: a leaf parser with a faithful render-back is a
ValidatedPrism: passvp.parse(raw)tofield(label, ...). (fields()accumulates siblings; a prism'sandThenshort-circuits nesting.) - Applicative: the
mapNfamily forKind-generic code - Multi-Edit and Sparse Updates: the same all-errors-at-once model for updating existing values