The EitherT Transformer:
Typed Errors in Any Context
"It is not down on any map; true places never are."
– Herman Melville, Moby-Dick
The Either inside a Future exists in a place Java's type system cannot natively map to. EitherT creates the map.
- How to combine async operations (CompletableFuture) with typed error handling (Either)
- Building workflows that can fail with specific domain errors while remaining async
- Using
fromKind,fromEither, andliftFto construct EitherT values - Real-world order processing with validation, inventory checks, and payment processing
- Why EitherT eliminates "callback hell" in complex async workflows
The Problem: Nested Async Errors
Consider a typical order processing flow. Each step is asynchronous and can fail with a domain error:
// Without EitherT: manual nesting
CompletableFuture<Either<DomainError, Receipt>> processOrder(OrderData data) {
return validateOrder(data).thenCompose(eitherValidated ->
eitherValidated.fold(
error -> CompletableFuture.completedFuture(Either.left(error)),
validated -> checkInventory(validated).thenCompose(eitherInventory ->
eitherInventory.fold(
error -> CompletableFuture.completedFuture(Either.left(error)),
inventory -> processPayment(inventory).thenCompose(eitherPayment ->
eitherPayment.fold(
error -> CompletableFuture.completedFuture(Either.left(error)),
payment -> createReceipt(payment)
))
))
));
}
Four steps, four levels of nesting, identical error-propagation boilerplate at every level. The actual business logic is buried inside the structure. Add error recovery for a specific step and this becomes nearly unreadable.
The Solution: EitherT
// With EitherT: flat composition
Kind<W, Receipt> processOrder(OrderData data) {
return For.from(eitherTMonad, EitherT.fromKind(validateOrder(data)))
.from(validated -> EitherT.fromKind(checkInventory(validated)))
.from(inventory -> EitherT.fromKind(processPayment(inventory)))
.from(payment -> EitherT.fromKind(createReceipt(payment)))
.yield((v, i, p, r) -> r);
}
Same four steps. No manual error propagation. If any step returns Left, subsequent steps are skipped automatically. The error type DomainError flows through the entire chain.
The Railway View
Right ═══●══════════●══════════●══════════●═══▶ Receipt
validate inventory payment receipt
(flatMap) (flatMap) (flatMap) (map)
╲ ╲ ╲
╲ ╲ ╲ Left: skip remaining steps
╲ ╲ ╲
Left ─────●─────────●─────────────●──────────▶ DomainError
InvalidOrder OutOfStock PaymentFailed
│
handleErrorWith optional recovery
│
●═══▶ recovered Right
Each flatMap runs inside the outer monad F (e.g. CompletableFuture). If the inner Either is Left, subsequent steps are skipped and the error propagates along the lower track. handleErrorWith can switch back to the success track for recoverable errors.
How EitherT Works
EitherT<F, L, R> wraps a value of type Kind<F, Either<L, R>>. It represents a computation within the context F that will eventually yield an Either<L, R>.
┌──────────────────────────────────────────────────────────┐
│ EitherT<CompletableFutureKind.Witness, Error, Value> │
│ │
│ ┌─── CompletableFuture ──────────────────────────────┐ │
│ │ │ │
│ │ ┌─── Either ──────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Left(error) │ Right(value) │ │ │
│ │ │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ flatMap ──▶ sequences F, then routes on Either │
│ map ──────▶ transforms Right(value) only │
│ raiseError ──▶ creates Left(error) in F │
│ handleErrorWith ──▶ recovers from Left │
└──────────────────────────────────────────────────────────┘
F: The witness type of the outer monad (e.g.,CompletableFutureKind.Witness). This monad handles the primary effect.L: The Left type of the innerEither, typically the error type.R: The Right type of the innerEither, typically the success value.
public record EitherT<F, L, R>(@NonNull Kind<F, Either<L, R>> value) {
/* ... static factories ... */ }
Setting Up EitherTMonad
The EitherTMonad<F, L> class implements MonadError<EitherTKind.Witness<F, L>, L>, providing the standard monadic operations for the combined structure. It requires a Monad<F> instance for the outer monad:
// F = CompletableFutureKind.Witness, L = DomainError
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad = CompletableFutureMonad.INSTANCE;
MonadError<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, DomainError> eitherTMonad =
new EitherTMonad<>(futureMonad);
Witness Type: EitherTKind<F, L, R> extends Kind<EitherTKind.Witness<F, L>, R>. The types F and L are fixed for a given context; R is the variable value type.
KindHelper: EitherTKindHelper provides EITHER_T.widen and EITHER_T.narrow for safe conversion between the concrete EitherT<F, L, R> and its Kind representation.
// Widen: concrete → Kind
Kind<EitherTKind.Witness<F, L>, R> kind = EITHER_T.widen(eitherT);
// Narrow: Kind → concrete
EitherT<F, L, R> concrete = EITHER_T.narrow(kind);
Key Operations
eitherTMonad.of(value): Lifts a pure valueAinto theEitherTcontext. Result:F<Right(A)>.eitherTMonad.map(f, eitherTKind): Applies functionA -> Bto theRightvalue inside the nested structure. IfLeft, the error propagates unchanged. Result:F<Either<L, B>>.eitherTMonad.flatMap(f, eitherTKind): The core sequencing operation. TakesA -> Kind<EitherTKind.Witness<F, L>, B>:- If
Left(l), propagatesF<Left(l)>(subsequent steps skipped) - If
Right(a), appliesf(a)to get the nextEitherT<F, L, B>
- If
eitherTMonad.raiseError(errorL): Creates anEitherTrepresenting a failure. Result:F<Left(L)>.eitherTMonad.handleErrorWith(eitherTKind, handler): Handles a failureLfrom the innerEither. IfRight(a), propagates unchanged. IfLeft(l), applieshandler(l)to attempt recovery.
Creating EitherT Instances
EitherT provides several factory methods for different starting points:
Monad<OptionalKind.Witness> optMonad = OptionalMonad.INSTANCE;
// 1. From a pure Right value: F<Right(value)>
EitherT<OptionalKind.Witness, String, String> etRight =
EitherT.right(optMonad, "OK");
// 2. From a pure Left value: F<Left(error)>
EitherT<OptionalKind.Witness, String, Integer> etLeft =
EitherT.left(optMonad, "FAILED");
// 3. From an existing Either: F<Either(input)>
Either<String, String> plainEither = Either.left("FAILED");
EitherT<OptionalKind.Witness, String, String> etFromEither =
EitherT.fromEither(optMonad, plainEither);
// 4. Lifting an outer monad value F<R> → F<Right(R)>
Kind<OptionalKind.Witness, Integer> outerOptional =
OPTIONAL.widen(Optional.of(123));
EitherT<OptionalKind.Witness, String, Integer> etLiftF =
EitherT.liftF(optMonad, outerOptional);
// 5. Wrapping an existing nested Kind F<Either<L, R>>
Kind<OptionalKind.Witness, Either<String, String>> nestedKind =
OPTIONAL.widen(Optional.of(Either.right("OK")));
EitherT<OptionalKind.Witness, String, String> etFromKind =
EitherT.fromKind(nestedKind);
// Accessing the wrapped value:
Kind<OptionalKind.Witness, Either<String, String>> wrappedValue = etRight.value();
Optional<Either<String, String>> unwrappedOptional = OPTIONAL.narrow(wrappedValue);
// → Optional.of(Either.right("OK"))
Real-World Example: Async Workflow with Error Handling
The problem: You need to validate input, process it asynchronously, and handle domain-specific errors at each step, all without nested thenCompose/fold chains.
The solution:
public class EitherTExample {
record DomainError(String message) {}
record ValidatedData(String data) {}
record ProcessedData(String data) {}
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad =
CompletableFutureMonad.INSTANCE;
MonadError<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>,
DomainError> eitherTMonad = new EitherTMonad<>(futureMonad);
// Sync validation returning Either
Kind<EitherKind.Witness<DomainError>, ValidatedData> validateSync(String input) {
if (input.isEmpty()) {
return EITHER.widen(Either.left(new DomainError("Input empty")));
}
return EITHER.widen(Either.right(new ValidatedData("Validated:" + input)));
}
// Async processing returning Future<Either>
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>>
processAsync(ValidatedData vd) {
CompletableFuture<Either<DomainError, ProcessedData>> future =
CompletableFuture.supplyAsync(() -> {
if (vd.data().contains("fail")) {
return Either.left(new DomainError("Processing failed"));
}
return Either.right(new ProcessedData("Processed:" + vd.data()));
});
return FUTURE.widen(future);
}
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>>
runWorkflow(String input) {
// Lift initial value into EitherT
var initialET = eitherTMonad.of(input);
// Step 1: Validate (sync Either → EitherT)
var validatedET = eitherTMonad.flatMap(
in -> EitherT.fromEither(futureMonad, EITHER.narrow(validateSync(in))),
initialET);
// Step 2: Process (async Future<Either> → EitherT)
var processedET = eitherTMonad.flatMap(
vd -> EitherT.fromKind(processAsync(vd)),
validatedET);
// Unwrap to get Future<Either>
return EITHER_T.narrow(processedET).value();
}
}
Why this works: Each step produces an EitherT value. The flatMap handles both the CompletableFuture sequencing and the Either error propagation. If validation returns Left, processing is skipped entirely. No manual error checking at any point.
Advanced Example: Error Recovery
The problem: A shipping step might fail with a temporary error, and you want to recover by using a default shipping option rather than failing the entire workflow.
The solution:
// Attempt shipment
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>,
ShipmentInfo> shipmentAttemptET =
EitherT.fromKind(steps.createShipmentAsync(orderId, address));
// Recover from specific errors
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>,
ShipmentInfo> recoveredShipmentET =
eitherTMonad.handleErrorWith(
shipmentAttemptET,
error -> {
if (error instanceof DomainError.ShippingError se
&& "Temporary Glitch".equals(se.reason())) {
// Recoverable: use default shipping
return eitherTMonad.of(new ShipmentInfo("DEFAULT_SHIPPING_USED"));
} else {
// Non-recoverable: re-raise
return eitherTMonad.raiseError(error);
}
});
The handleErrorWith only fires when the inner Either is Left. The outer CompletableFuture context is preserved throughout.
- Mixing up
fromEitherandfromKind: UsefromEitherwhen you have a plainEither<L, R>(e.g., from a synchronous validation). UsefromKindwhen you haveKind<F, Either<L, R>>(e.g., from an async operation that already returnsFuture<Either>). - Forgetting
.value(): The final result of anEitherTchain is still anEitherT. Call.value()to extract the underlyingKind<F, Either<L, R>>when you need to interact with the outer monad directly.
Without HKT simulation, you would need a separate transformer for each outer monad: EitherTOptional, EitherTFuture, EitherTIO, and so on. Higher-Kinded-J's Kind<F, A> interface means there is one EitherT and one EitherTMonad that works generically for any outer monad F for which a Monad<F> instance exists. You provide the outer monad at construction time; the transformer does the rest.
- Monad Transformers - General concept and choosing the right transformer
- OptionalT - When your inner effect is absence rather than typed errors
- Either Monad - The underlying Either type
- Order Processing Walkthrough - Complete EitherT example with CompletableFuture
- Railway Oriented Programming - Scott Wlaschin's classic talk on functional error handling (60 min watch)
Previous: Monad Transformers Next: OptionalT