Applicatives and validation
An eligibility application fails several checks at once: the applicant is under age, the declared income is over the limit, and no documents were attached. Telling the citizen only about the age failure — then the income failure on resubmission, then the documents — is the UX of a flatMap chain, which short-circuits at the first error. Validation needs the opposite semantics: run every check independently and accumulate all the failures.
That is what applicatives are for. Where a monad sequences dependent steps (Part 9), an applicative combines independent computations. In Java terms: map2 takes two Validated values and a combining function, and when both failed it merges the errors instead of dropping one.
The accumulator: NonEmptyList
Errors accumulate into a list that is guaranteed non-empty — an Invalid with zero errors would be a lie:
public record NonEmptyList<T>(T head, List<T> tail) {
@SafeVarargs public static <T> NonEmptyList<T> of(T head, T... rest) { return new NonEmptyList<>(head, List.of(rest)); }
public NonEmptyList<T> append(NonEmptyList<T> other) { /* head + both tails */ }
public static <T> Semigroup<NonEmptyList<T>> semigroup() { return NonEmptyList::append; }}The Semigroup is what map2 uses to merge two error collections. A semigroup is just an associative combine operation — Part 12 builds it properly; for now read NonEmptyList.semigroup() as “concatenate both error lists.”
Validated and map2
public sealed interface Validated<E, T> {
<R> Validated<E, R> map(Fn1<? super T, ? extends R> mapper);
static <E, T> Validated<E, T> valid(T value) { return new Valid<>(value); } static <E, T> Validated<E, T> invalid(E error) { return new Invalid<>(error); }
static <E, A, B, C> Validated<E, C> map2( Validated<E, A> first, Validated<E, B> second, Semigroup<E> semigroup, BiFunction<? super A, ? super B, ? extends C> combine) { return switch (first) { case Valid<E, A>(var a) -> switch (second) { case Valid<E, B>(var b) -> valid(combine.apply(a, b)); case Invalid<E, B>(var error) -> invalid(error); }; case Invalid<E, A>(var errorA) -> switch (second) { case Valid<E, B>(var b) -> invalid(errorA); case Invalid<E, B>(var errorB) -> invalid(semigroup.combine(errorA, errorB)); }; }; }
record Valid<E, T>(T value) implements Validated<E, T> { /* map applies the function */ } record Invalid<E, T>(E error) implements Validated<E, T> { /* map passes the error through */ }}Read the four cases: both valid → combine the values; one invalid → keep that error; both invalid → merge the errors. That last case is the entire difference from Result.flatMap, which would have kept only errorA.
Scheme example: validating an application
Each check is a pure function from the input to a Validated — either the checked field or a one-element error list:
public static Validated<NonEmptyList<String>, Integer> checkAge( ApplicationInput input, PolicyEnvironment environment) { return input.age() >= environment.minimumAge() ? Validated.valid(input.age()) : Validated.invalid(NonEmptyList.of( "age " + input.age() + " is below the minimum " + environment.minimumAge()));}checkIncome and checkDocuments follow the same pattern, and map2 threads them together:
public static Validated<NonEmptyList<String>, ApplicationInput> validate( ApplicationInput input, PolicyEnvironment environment) { Semigroup<NonEmptyList<String>> errors = NonEmptyList.semigroup(); return Validated.map2( Validated.map2( checkAge(input, environment), checkIncome(input, environment), errors, Tuple2::new), checkDocuments(input), errors, (ageAndIncome, documents) -> new ApplicationInput( input.citizenId(), ageAndIncome.first(), ageAndIncome.second(), input.resident(), documents));}An input of age=16, income=900_000, documents=[] returns all three failures in one Invalid:
Invalid(["age 16 is below the minimum 18", "declared income exceeds the limit of 300000", "no supporting documents supplied"])The decision rule
- Dependent steps, stop at first failure →
Result+flatMap(Part 9). Fetching a land record requires the citizen lookup to have succeeded. - Independent checks, report every failure →
Validated+map2. Form validation, eligibility pre-checks, document requirements. Validatedis deliberately not a monad — if it had a lawfulflatMapit would have to short-circuit, which is exactly the behavior it exists to avoid. Part 10’s laws make that precise.