Series overview
Part 8 of 2040% complete
2026-04-10•25 min read

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:

data/NonEmptyList.java
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

data/Validated.java
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:

scheme/ApplicationValidator.java
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:

scheme/ApplicationValidator.java
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));
}

checkAge

map2

checkIncome

checkDocuments

Valid ApplicationInput

or all errors

checkAge

map2

checkIncome

checkDocuments

Valid ApplicationInput

or all errors

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.
  • Validated is deliberately not a monad — if it had a lawful flatMap it would have to short-circuit, which is exactly the behavior it exists to avoid. Part 10’s laws make that precise.
JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind