MonadError: Handling Errors Gracefully
"The test of a first-rate intelligence is the ability to hold two opposed ideas in mind at the same time."
– F. Scott Fitzgerald, The Crack-Up
A resilient workflow holds two paths in mind simultaneously: the path where everything succeeds, and the path where things go wrong. MonadError is the type class that lets us spell both cleanly, in the same code, without sliding into nested try/catch.
- How
MonadErrorextendsMonadwith a typed notion of failure - Using
raiseErrorto construct a failed computation declaratively - Recovering with
handleErrorWith(effect-level) andhandleError(value-level) - Chaining recovery so each fallback only fires when the previous one fails
- Where
MonadErrorshows up inside the Foundations one-liner
The Problem: Scattered Try-Catch
A configuration loader parses a file, validates the parsed settings, then opens a database connection. Each step can fail with a meaningful error, and we want different recovery for each. In imperative Java, we end up with nested try/catch:
try {
Config config = parseConfigFile(path);
try {
Settings settings = validateSettings(config);
try {
return connectToDatabase(settings);
} catch (DbException e) {
return connectToFallbackDb(settings);
}
} catch (ValidationException e) {
log.error("Bad config: " + e.getMessage());
throw e;
}
} catch (ParseException e) {
return loadDefaultConfig();
}
Three levels of nesting, three different recovery rules, and the business logic is sandwiched between them. Reordering the steps means re-arranging the pyramid. Adding a fourth step means adding a fourth level. Reading the code top to bottom does not tell us what the workflow does; it tells us how the author chose to indent.
The Solution: raiseError and handleErrorWith
MonadError extends Monad with two operations that turn try/catch inside out.
raiseError(E error)constructs a failed computation by lifting an error into the monadic context.handleErrorWith(fa, handler)inspects a failure and provides a fallback computation.
raiseError("config not found")
│
▼
Kind<F, A> = Left("config not found")
│
├── handleErrorWith ──> recovery function ──> Kind<F, A> = Right(defaults)
│
└── (no handler) ──> propagates as Left("config not found")
The same workflow, rebuilt with MonadError over Either<String, A>:
import org.higherkindedj.hkt.Kind;
import org.higherkindedj.hkt.either.Either;
import org.higherkindedj.hkt.either.EitherKind;
import org.higherkindedj.hkt.either.EitherMonad;
import static org.higherkindedj.hkt.either.EitherKindHelper.EITHER;
MonadError<EitherKind.Witness<String>, String> me = Instances.monadError(either());
public Kind<EitherKind.Witness<String>, Config> parseConfig(String path) {
if (!Files.exists(Path.of(path))) {
return me.raiseError("Config file not found: " + path);
}
return me.of(Config.parse(path));
}
public Kind<EitherKind.Witness<String>, Settings> validate(Config config) {
if (config.dbHost().isBlank()) {
return me.raiseError("Missing required setting: db.host");
}
return me.of(Settings.from(config));
}
public Kind<EitherKind.Witness<String>, Connection> connect(Settings settings) {
if (!settings.isReachable()) {
return me.raiseError("Cannot reach database: " + settings.dbHost());
}
return me.of(Connection.open(settings));
}
// Compose the workflow with flatMap, then layer recovery on top
Kind<EitherKind.Witness<String>, Connection> workflow =
me.flatMap(config ->
me.flatMap(settings ->
connect(settings),
validate(config)),
parseConfig("/etc/app.conf"));
// Recover from connection failures by trying a fallback database
Kind<EitherKind.Witness<String>, Connection> resilient = me.handleErrorWith(
workflow,
error -> {
if (error.startsWith("Cannot reach database")) {
return connect(Settings.fallback());
}
return me.raiseError(error);
});
The business logic reads top to bottom. Recovery is a separate layer applied at the end. Re-ordering steps means re-ordering flatMap calls; adding a step means adding a flatMap call. The shape of the code matches the shape of the problem.
@NullMarked
public interface MonadError<F extends WitnessArity<TypeArity.Unary>, E> extends Monad<F> {
<A> @NonNull Kind<F, A> raiseError(@Nullable E error);
<A> @NonNull Kind<F, A> handleErrorWith(
Kind<F, A> ma,
Function<? super E, ? extends Kind<F, A>> handler);
// Value-level recovery; the handler returns a plain A which is auto-lifted
default <A> @NonNull Kind<F, A> handleError(
Kind<F, A> ma,
Function<? super E, ? extends A> handler) {
return handleErrorWith(ma, error -> of(handler.apply(error)));
}
}
Recovery Patterns
Value-Level Recovery with handleError
The problem. An operation might fail, and we have a sensible default value sitting in plain Java.
The solution. handleError takes a function E -> A and lifts the result back into the monad for us.
MonadError<EitherKind.Witness<String>, String> me = Instances.monadError(either());
Kind<EitherKind.Witness<String>, Integer> safeDivide(int a, int b) {
return b == 0
? me.raiseError("Cannot divide by zero")
: me.of(a / b);
}
Kind<EitherKind.Witness<String>, Integer> result = me.handleError(
safeDivide(10, 0),
error -> 0);
// Right(0)
Effect-Level Recovery with handleErrorWith
The problem. Recovery itself might fail. Falling back to a secondary database is no good if that database is also down.
The solution. handleErrorWith takes E -> Kind<F, A>. The recovery function can return a success, another failure, or whatever the type allows.
Kind<EitherKind.Witness<String>, Integer> result = me.handleErrorWith(
safeDivide(10, 0),
error -> {
log.warn("Division failed: " + error + ", trying alternative");
return safeDivide(10, 2);
});
// Right(5)
Chained Recovery
The problem. Several fallbacks, each able to fail.
The solution. Stack handleErrorWith calls. Each layer only triggers when the previous one is still failing.
Kind<EitherKind.Witness<String>, Config> config =
me.handleErrorWith(
me.handleErrorWith(
loadConfigFromFile(),
e -> loadConfigFromEnv()),
e -> me.of(Config.defaults()));
// File first, then environment, then defaults
For longer fallback chains, Effect Path's recoverWith reads more naturally; the same logic, less ceremony.
Back to the One-Liner
In the line we keep returning to:
repo.find(id)
.toEitherPath() // <-- raiseError equivalent: absence becomes a typed Left
.focus().attributes().at(key)
.modify(spec::validateAndCoerce)
.flatMap(repo::save);
.toEitherPath() is the user-facing surface of raiseError for the absence case: a missing record becomes a typed Left carrying the not-found story. Anywhere downstream we wanted to recover, handleErrorWith (or its Effect Path sibling recoverWith) is the door we would walk through. We did not need either in this line because the contract of the service method is "fail loudly to the caller", but the moment we want a per-error recovery rule, MonadError is the layer that hosts it.
Things People Get Wrong
- "
raiseErrorthrows an exception." It does not. It constructs a value ofKind<F, A>that represents failure. Nothing is thrown, and the rest of the chain politely skips itself. The thrown-exception equivalent isIO.delay(() -> { throw ...; }), and even that does not throw until interpretation. - "
handleErrorWithand a try/catch are the same." Mechanically similar, semantically different. A try/catch is wired to call-stack unwinding;handleErrorWithis just a function call that runs on a value. The latter composes; the former does not. - "I have to use
Either." AnyMonadErrorinstance works:Try,Validated(in its Monad shape),CompletableFutureMonad,IOMonad, the*Pathtypes. Pick the error type that fits the domain; the recovery story is the same. - "Recovery is for the end of the chain." It can be anywhere. Layering
handleErrorWithbetween twoflatMapcalls is the way we say "if step three fails, try step three again with a different argument before continuing".
raiseErrorcreates a failed computation declaratively, with no thrown exceptionhandleErrorWithis effect-level recovery; the handler can itself succeed or failhandleErroris value-level recovery; the handler returns a plain value that is auto-lifted- Recovery composes by stacking; each layer only fires when the previous one is still failing
- Code written against
MonadError<F, E>works withEither,Try,Validated,IO, or any other error-capable monad
- Monad - The base type class that MonadError extends
- Either - The most common MonadError instance for typed errors
- Try - MonadError specialised for
Throwableerrors - One Line, Six Layers - Where this fits in the wider Foundations picture
- Baeldung: Functional Programming in Java - Practical guide to functional patterns in Java
- Mark Seemann: An Either Functor - Step-by-step introduction to Either as a functional error-handling tool
Practice error handling in Tutorial 05: Monad Error Handling (7 exercises, ~10 minutes).
Previous: Monad Next: Semigroup and Monoid