Applicative: Combining Independent Computations
- How
aplets us apply a wrapped function to a wrapped value - Why
map2,map3, and friends are the practical workhorses we will reach for - Why
Applicativeis the right tool when we want to accumulate errors rather than stop at the first - Where
Applicativesits betweenFunctorandMonad, and when each one earns its keep
When map Is Not Enough
Functor lets us run a function over a single container. Most real code reaches for two or three containers at once.
A registration form has a username, a password, and an email. Each one is validated independently. We want to combine the three results into a User if everything is fine, or return every error we found if anything is wrong. Functor.map cannot help here; it only knows how to lift one input.
Applicative: independent paths, combined at the end
validateName("") ────┐
validateEmail("bad") ────┼──> map3 ──> Validated<User>
validatePassword("?") ────┘
(all three run, errors accumulate)
Applicative is the type class that names this pattern. It adds two operations to Functor and gets a fistful of useful combinators in return.
What an Applicative Provides
An Applicative<F> is a Functor<F> plus two methods:
of(sometimes calledpure): lift a plain value into the container.of(42)becomesOptional.of(42),Either.right(42),Just(42), depending on the instance.ap: apply a function that lives inside the container to a value that also lives inside the container.ap(Optional<Function<A, B>>, Optional<A>) -> Optional<B>.
ap is the part that surprises new readers, because we rarely write Optional<Function<A, B>> ourselves. We do not need to. The library uses ap and map together to build map2, map3, map4, and so on, and those are what we actually call.
@NullMarked
public interface Applicative<F extends WitnessArity<TypeArity.Unary>> extends Functor<F> {
<A> @NonNull Kind<F, A> of(@Nullable A value);
<A, B> @NonNull Kind<F, B> ap(
Kind<F, ? extends Function<A, B>> ff,
Kind<F, A> fa);
default <A, B, C> @NonNull Kind<F, C> map2(
Kind<F, A> fa,
Kind<F, B> fb,
BiFunction<? super A, ? super B, ? extends C> f) {
return ap(map(a -> b -> f.apply(a, b), fa), fb);
}
// map3, map4, map5 build similarly on top of ap and map
}
The Reason We Care: Error Accumulation
Monad.flatMap short-circuits on the first failure. That is exactly what we want for sequencing dependent steps, and exactly the wrong thing when we want to validate a form. A user who submits a bad username, a bad password, and a bad email expects to see all three errors at once, not have to fix one and resubmit three times.
Applicative does not short-circuit. Every input runs, and the results combine through whatever rule the container defines. For Validated paired with a Semigroup, that rule is "concatenate the errors".
import org.higherkindedj.hkt.Kind;
import org.higherkindedj.hkt.validated.Validated;
import org.higherkindedj.hkt.validated.ValidatedMonad;
import org.higherkindedj.hkt.Semigroups;
import java.util.List;
import static org.higherkindedj.hkt.validated.ValidatedKindHelper.VALIDATED;
record User(String username, String password) {}
public Validated<List<String>, String> validateUsername(String username) {
if (username.length() < 3) {
return Validated.invalid(List.of("Username must be at least 3 characters"));
}
return Validated.valid(username);
}
public Validated<List<String>, String> validatePassword(String password) {
if (!password.matches(".*\\d.*")) {
return Validated.invalid(List.of("Password must contain a number"));
}
return Validated.valid(password);
}
// An Applicative for Validated, with a Semigroup that concatenates error lists
Applicative<ValidatedKind.Witness<List<String>>> applicative =
Instances.validated(Semigroups.list());
// All checks pass
Kind<ValidatedKind.Witness<List<String>>, User> ok =
applicative.map2(
VALIDATED.widen(validateUsername("test_user")),
VALIDATED.widen(validatePassword("password123")),
User::new);
// Valid(User[username=test_user, password=password123])
// Both checks fail; both errors land in the result
Kind<ValidatedKind.Witness<List<String>>, User> bad =
applicative.map2(
VALIDATED.widen(validateUsername("no")),
VALIDATED.widen(validatePassword("bad")),
User::new);
// Invalid([Username must be at least 3 characters, Password must contain a number])
A Monad cannot do this. After the first Invalid, flatMap would stop. The user would fix one error, resubmit, find another, and silently learn to dread our forms. Applicative is the polite choice.
When to Use Applicative Instead of Monad
The rule of thumb is mechanical:
- If the next step needs to look at the previous result before it can run, we need
Monad.flatMap. - If the steps are independent,
Applicative.mapNis enough, and we get richer error semantics for free.
Applicative is also the layer that allows parallel evaluation, since the inputs do not depend on each other. Monad cannot promise parallelism in general, because step n+1 might be a function of step n's value.
For a longer treatment with a decision flow, see Choosing Your Abstraction Level.
Things People Get Wrong
- "
Applicativeis justMonadminusflatMap." That is true mechanically and misses the point. The reasonApplicativeexists is precisely because some types (likeValidatedwith aSemigroup) have a usefulApplicativeinstance with different behaviour from theirMonadinstance.Validated'sflatMapis fail-fast; itsapis fail-slow. Same type, two different stories, depending on which type class we ask. - "
map2is only useful with two arguments." It is the entry point. Most real code reaches formap3,map4, ormap5to combine four or five validated fields. The names get tedious past five; for those cases, For comprehensions read better. - "
apis the operation I will call directly." Almost never.apis the primitive thatmapNis built on. We define newApplicativeinstances by implementingapandof, but we use them throughmap2and friends. - "Error accumulation only works with
Validated." It works with anyApplicativewhose instance defines an accumulating combine rule.Validatedis the most common, but the same machinery applies to other accumulating types we might define ourselves.
Applicativecombines independent computations, whereMonadsequences dependent onesmap2,map3, and friends are the everyday surface;apandofare the primitives that build them- Error accumulation with
Validatedis the canonical reason to reach forApplicativerather thanMonad - Same type, different type-class instance, different story;
Validated'sflatMapandapdeliberately disagree
- Functor - The simpler foundation that Applicative extends
- Monad - For dependent computations, but with short-circuiting, not accumulation
- Choosing Your Abstraction Level - When to reach for which one
- Validated - The type designed for error accumulation
- Baeldung: Functional Programming in Java - Practical functional patterns for Java developers
- Mark Seemann: Applicative Functors - Accessible introduction with practical examples
Practice Applicative combining in Tutorial 03: Applicative Combining (7 exercises, ~10 minutes).
Previous: Functor Next: Alternative