The EitherT Transformer:
Combining Monadic Effects
- 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
EitherT Monad Transformer.
EitherT<F, L, R>: Combining any Monad F with Either<L, R>
The EitherT monad transformer allows you to combine the error-handling capabilities of Either<L, R> with another outer monad F. It transforms a computation that results in Kind<F, Either<L, R>> into a single monadic structure that can be easily composed. This is particularly useful when dealing with operations that can fail (represented by Left<L>) within an effectful context F (like asynchronous operations using CompletableFutureKind or computations involving state with StateKind).
F: The witness type of the outer monad (e.g.,CompletableFutureKind.Witness,OptionalKind.Witness). This monad handles the primary effect (e.g., asynchronicity, optionality).L: The Left type of the innerEither. This typically represents the error type for the computation or alternative result.R: The Right type of the innerEither. This typically represents the success value type.
public record EitherT<F, L, R>(@NonNull Kind<F, Either<L, R>> value) {
/* ... static factories ... */ }
It holds a value of type Kind<F, Either<L, R>>. The real power comes from its associated type class instance, EitherTMonad.
Essentially, 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>.
The primary goal of EitherT is to provide a unified Monad interface (specifically MonadError for the L type) for this nested structure, hiding the complexity of manually handling both the outer F context and the inner Either context.
EitherTKind<F, L, R>: The Witness Type
Just like other types in the Higher-Kinded-J, EitherT needs a corresponding Kind interface to act as its witness type in generic functions. This is EitherTKind<F, L, R>.
- It extends
Kind<G, R>whereG(the witness for the combined monad) isEitherTKind.Witness<F, L>. FandLare fixed for a specificEitherTcontext, whileRis the variable type parameterAinKind<G, A>.
You'll primarily interact with this type when providing type signatures or receiving results from EitherTMonad methods.
EitherTKindHelper
- Provides widen and narrow methods to safely convert between the concrete
EitherT<F, L, R>and its Kind representation (Kind<EitherTKind<F, L, ?>, R>).
EitherTMonad<F, L>: Operating on EitherT
- The EitherTMonad class implements
MonadError<EitherTKind.Witness<F, L>, L>.
- It requires a Monad
instance for the outer monad F to be provided during construction. This outer monad instance is used internally to handle the effects of F. - It uses
EITHER_T.widenandEITHER_T.narrowinternally to manage the conversion between theKindand the concreteEitherT. - The error type E for MonadError is fixed to L, the 'Left' type of the inner Either. Error handling operations like
raiseError(L l)will create anEitherTrepresentingF<Left(l)>, andhandleErrorWithallows recovering from such Left states.
// Example: F = CompletableFutureKind.Witness, L = DomainError
// 1. Get the MonadError instance for the outer monad F
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad = CompletableFutureMonad.INSTANCE;
// 2. Create the EitherTMonad, providing the outer monad instance
// This EitherTMonad handles DomainError for the inner Either.
MonadError<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, DomainError> eitherTMonad =
new EitherTMonad<>(futureMonad);
// Now 'eitherTMonad' can be used to operate on Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, A> values.
eitherTMonad.of(value): Lifts a pure valueAinto theEitherTcontext. Result:F<Right(A)>.eitherTMonad.map(f, eitherTKind): Applies functionA -> Bto theRightvalue inside the nested structure, preserving bothFandEithercontexts (if Right). Result:F<Either<L, B>>.eitherTMonad.flatMap(f, eitherTKind): The core sequencing operation. Takes a functionA -> Kind<EitherTKind.Witness<F, L>, B>(i.e.,A -> EitherT<F, L, B>). It unwraps the inputEitherT, handles theFcontext, checks the innerEither:- If
Left(l), it propagatesF<Left(l)>. - If
Right(a), it appliesf(a)to get the nextEitherT<F, L, B>, and extracts its innerKind<F, Either<L, B>>, effectively chaining theFcontexts and theEitherlogic.
- If
eitherTMonad.raiseError(errorL): Creates anEitherTrepresenting a failure in the innerEither. Result:F<Left(L)>.eitherTMonad.handleErrorWith(eitherTKind, handler): Handles a failureLfrom the innerEither. Takes a handlerL -> Kind<EitherTKind.Witness<F, L>, A>. It unwraps the inputEitherT, checks the innerEither:- If
Right(a), propagatesF<Right(a)>. - If
Left(l), applieshandler(l)to get a recoveryEitherT<F, L, A>, and extracts its innerKind<F, Either<L, A>>.
- If
You typically create EitherT instances using its static factory methods, providing the necessary outer Monad<F> instance:
// Assume:
Monad<OptionalKind.Witness> optMonad = OptionalMonad.INSTANCE; // Outer Monad F=Optional
String errorL = "FAILED";
String successR = "OK";
Integer otherR = 123;
// 1. Lifting a pure 'Right' value: Optional<Right(R)>
EitherT<OptionalKind.Witness, String, String> etRight = EitherT.right(optMonad, successR);
// Resulting wrapped value: Optional.of(Either.right("OK"))
// 2. Lifting a pure 'Left' value: Optional<Left(L)>
EitherT<OptionalKind.Witness, String, Integer> etLeft = EitherT.left(optMonad, errorL);
// Resulting wrapped value: Optional.of(Either.left("FAILED"))
// 3. Lifting a plain Either: Optional<Either(input)>
Either<String, String> plainEither = Either.left(errorL);
EitherT<OptionalKind.Witness, String, String> etFromEither = EitherT.fromEither(optMonad, plainEither);
// Resulting wrapped value: Optional.of(Either.left("FAILED"))
// 4. Lifting an outer monad value F<R>: Optional<Right(R)>
Kind<OptionalKind.Witness, Integer> outerOptional = OPTIONAL.widen(Optional.of(otherR));
EitherT<OptionalKind.Witness, String, Integer> etLiftF = EitherT.liftF(optMonad, outerOptional);
// Resulting wrapped value: Optional.of(Either.right(123))
// 5. Wrapping an existing nested Kind: F<Either<L, R>>
Kind<OptionalKind.Witness, Either<String, String>> nestedKind =
OPTIONAL.widen(Optional.of(Either.right(successR)));
EitherT<OptionalKind.Witness, String, String> etFromKind = EitherT.fromKind(nestedKind);
// Resulting wrapped value: Optional.of(Either.right("OK"))
// Accessing the wrapped value:
Kind<OptionalKind.Witness, Either<String, String>> wrappedValue = etRight.value();
Optional<Either<String, String>> unwrappedOptional = OPTIONAL.narrow(wrappedValue);
// unwrappedOptional is Optional.of(Either.right("OK"))
The most common use case for EitherT is combining asynchronous operations (CompletableFuture) with domain error handling (Either). The OrderWorkflowRunner class provides a detailed example.
Here's a simplified conceptual structure based on that example:
public class EitherTExample {
// --- Setup ---
// Assume DomainError is a sealed interface for specific errors
// Re-defining a local DomainError to avoid dependency on the full DomainError hierarchy for this isolated example.
// In a real scenario, you would use the shared DomainError.
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);
// --- Workflow Steps (returning Kinds) ---
// Simulates a sync validation returning Either
Kind<EitherKind.Witness<DomainError>, ValidatedData> validateSync(String input) {
System.out.println("Validating synchronously...");
if (input.isEmpty()) {
return EITHER.widen(Either.left(new DomainError("Input empty")));
}
return EITHER.widen(Either.right(new ValidatedData("Validated:" + input)));
}
// Simulates an async processing step returning Future<Either>
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>> processAsync(ValidatedData vd) {
System.out.println("Processing asynchronously for: " + vd.data());
CompletableFuture<Either<DomainError, ProcessedData>> future =
CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(50);
} catch (InterruptedException e) { /* ignore */ }
if (vd.data().contains("fail")) {
return Either.left(new DomainError("Processing failed"));
}
return Either.right(new ProcessedData("Processed:" + vd.data()));
});
return FUTURE.widen(future);
}
// Function to run the workflow for given input
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>> runWorkflow(String initialInput) {
// Start with initial data lifted into EitherT
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, String> initialET = eitherTMonad.of(initialInput);
// Step 1: Validate (Sync Either lifted into EitherT)
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, ValidatedData> validatedET =
eitherTMonad.flatMap(
input -> {
// Call sync step returning Kind<EitherKind.Witness,...>
// Correction 1: Use EitherKind.Witness here
Kind<EitherKind.Witness<DomainError>, ValidatedData> validationResult = validateSync(input);
// Lift the Either result into EitherT using fromEither
return EitherT.fromEither(futureMonad, EITHER.narrow(validationResult));
},
initialET
);
// Step 2: Check Inventory (Asynchronous - returns Future<Either<DomainError, Unit>>)
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, WorkflowContext> inventoryET =
eitherTMonad.flatMap( // Chain from validation result
ctx -> { // Executed only if validatedET was F<Right(...)>
// Call async step -> Kind<CompletableFutureKind.Witness, Either<DomainError, Unit>>
Kind<CompletableFutureKind.Witness, Either<DomainError, Unit>> inventoryCheckFutureKind =
steps.checkInventoryAsync(ctx.validatedOrder().productId(), ctx.validatedOrder().quantity());
// Lift the F<Either> directly into EitherT using fromKind
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, Unit> inventoryCheckET =
EitherT.fromKind(inventoryCheckFutureKind);
// If inventory check resolves to Right (now Right(Unit.INSTANCE)), update context.
return eitherTMonad.map(unitInstance -> ctx.withInventoryChecked(), inventoryCheckET);
},
validatedET // Input is result of validation step
);
// Unwrap the final EitherT to get the underlying Future<Either>
return ((EitherT<CompletableFutureKind.Witness, DomainError, ProcessedData>) processedET).value();
}
public void asyncWorkflowErrorHandlingExample(){
// --- Workflow Definition using EitherT ---
// Input data
String inputData = "Data";
String badInputData = "";
String processingFailData = "Data-fail";
// --- Execution ---
System.out.println("--- Running Good Workflow ---");
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>> resultGoodKind = runWorkflow(inputData);
System.out.println("Good Result: "+FUTURE.join(resultGoodKind));
// Expected: Right(ProcessedData[data=Processed:Validated:Data])
System.out.println("\n--- Running Bad Input Workflow ---");
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>> resultBadInputKind = runWorkflow(badInputData);
System.out.println("Bad Input Result: "+ FUTURE.join(resultBadInputKind));
// Expected: Left(DomainError[message=Input empty])
System.out.println("\n--- Running Processing Failure Workflow ---");
Kind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>> resultProcFailKind = runWorkflow(processingFailData);
System.out.println("Processing Fail Result: "+FUTURE.join(resultProcFailKind));
// Expected: Left(DomainError[message=Processing failed])
}
public static void main(String[] args){
EitherTExample example = new EitherTExample();
example.asyncWorkflowErrorHandlingExample();
}
}
This example demonstrates:
- Instantiating
EitherTMonadwith the outerCompletableFutureMonad. - Lifting the initial value using
eitherTMonad.of. - Using
eitherTMonad.flatMapto sequence steps. - Lifting a synchronous
Eitherresult intoEitherTusingEitherT.fromEither. - Lifting an asynchronous
Kind<F, Either<L,R>>result usingEitherT.fromKind. - Automatic short-circuiting: If validation returns
Left, the processing step is skipped. - Unwrapping the final
EitherTusing.value()to get theKind<CompletableFutureKind.Witness, Either<DomainError, ProcessedData>>result.
The primary use is chaining operations using flatMap and handling errors using handleErrorWith or related methods. The OrderWorkflowRunner is the best example. Let's break down a key part:
// --- From OrderWorkflowRunner.java ---
// Assume setup:
// F = CompletableFutureKind<?>
// L = DomainError
// futureMonad = CompletableFutureMonad.INSTANCE;
// eitherTMonad = new EitherTMonad<>(futureMonad);
// steps = new OrderWorkflowSteps(dependencies); // Contains workflow logic
// Initial Context (lifted)
WorkflowContext initialContext = WorkflowContext.start(orderData);
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, WorkflowContext> initialET =
eitherTMonad.of(initialContext); // F<Right(initialContext)>
// Step 1: Validate Order (Synchronous - returns Either)
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, WorkflowContext> validatedET =
eitherTMonad.flatMap( // Use flatMap on EitherTMonad
ctx -> { // Lambda receives WorkflowContext if initialET was Right
// Call sync step -> Either<DomainError, ValidatedOrder>
Either<DomainError, ValidatedOrder> syncResultEither =
EITHER.narrow(steps.validateOrder(ctx.initialData()));
// Lift sync Either into EitherT: -> F<Either<DomainError, ValidatedOrder>>
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, ValidatedOrder>
validatedOrderET = EitherT.fromEither(futureMonad, syncResultEither);
// If validation produced Left, map is skipped.
// If validation produced Right(vo), map updates the context: F<Right(ctx.withValidatedOrder(vo))>
return eitherTMonad.map(ctx::withValidatedOrder, validatedOrderET);
},
initialET // Input to the flatMap
);
// Step 2: Check Inventory (Asynchronous - returns Future<Either<DomainError, Void>>)
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, WorkflowContext> inventoryET =
eitherTMonad.flatMap( // Chain from validation result
ctx -> { // Executed only if validatedET was F<Right(...)>
// Call async step -> Kind<CompletableFutureKind.Witness, Either<DomainError, Void>>
Kind<CompletableFutureKind.Witness, Either<DomainError, Void>> inventoryCheckFutureKind =
steps.checkInventoryAsync(ctx.validatedOrder().productId(), ctx.validatedOrder().quantity());
// Lift the F<Either> directly into EitherT using fromKind
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, Void> inventoryCheckET =
EitherT.fromKind(inventoryCheckFutureKind);
// If inventory check resolves to Right, update context. If Left, map is skipped.
return eitherTMonad.map(ignored -> ctx.withInventoryChecked(), inventoryCheckET);
},
validatedET // Input is result of validation step
);
// Step 4: Create Shipment (Asynchronous with Recovery)
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, WorkflowContext> shipmentET =
eitherTMonad.flatMap( // Chain from previous step
ctx -> {
// Call async shipment step -> F<Either<DomainError, ShipmentInfo>>
Kind<CompletableFutureKind.Witness, Either<DomainError, ShipmentInfo>> shipmentAttemptFutureKind =
steps.createShipmentAsync(ctx.validatedOrder().orderId(), ctx.validatedOrder().shippingAddress());
// Lift into EitherT
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, ShipmentInfo> shipmentAttemptET =
EitherT.fromKind(shipmentAttemptFutureKind);
// *** Error Handling using MonadError ***
Kind<EitherTKind.Witness<CompletableFutureKind.Witness, DomainError>, ShipmentInfo> recoveredShipmentET =
eitherTMonad.handleErrorWith( // Operates on the EitherT value
shipmentAttemptET,
error -> { // Lambda receives DomainError if shipmentAttemptET resolves to Left(error)
if (error instanceof DomainError.ShippingError se && "Temporary Glitch".equals(se.reason())) {
// Specific recoverable error: Return a *successful* EitherT
return eitherTMonad.of(new ShipmentInfo("DEFAULT_SHIPPING_USED"));
} else {
// Non-recoverable error: Re-raise it within EitherT
return eitherTMonad.raiseError(error); // Returns F<Left(error)>
}
});
// Map the potentially recovered result to update context
return eitherTMonad.map(ctx::withShipmentInfo, recoveredShipmentET);
},
paymentET // Assuming paymentET was the previous step
);
// ... rest of workflow ...
// Final unwrap
// EitherT<CompletableFutureKind.Witness, DomainError, FinalResult> finalET = ...;
// Kind<CompletableFutureKind.Witness, Either<DomainError, FinalResult>> finalResultKind = finalET.value();
This demonstrates how EitherTMonad.flatMap sequences the steps, while EitherT.fromEither, EitherT.fromKind, and eitherTMonad.of/raiseError/handleErrorWith manage the lifting and error handling within the combined Future<Either<...>> context.
The Higher-Kinded-J library simplifies the implementation and usage of concepts like monad transformers (e.g., EitherT) in Java precisely because it simulates Higher-Kinded Types (HKTs). Here's how:
-
The Core Problem Without HKTs: Java's type system doesn't allow you to directly parameterize a type by a type constructor like
List,Optional, orCompletableFuture. You can writeList<String>, but you cannot easily write a generic classTransformer<F, A>whereFitself represents any container type (likeList<_>) andAis the value type. This limitation makes defining general monad transformers rather difficult. A monad transformer likeEitherTneeds to combine an arbitrary outer monadFwith the innerEithermonad. Without HKTs, you would typically have to:- Create separate, specific transformers for each outer monad (e.g.,
EitherTOptional,EitherTFuture,EitherTIO). This leads to significant code duplication. - Resort to complex, often unsafe casting or reflection.
- Write extremely verbose code manually handling the nested structure for every combination.
- Create separate, specific transformers for each outer monad (e.g.,
-
How this helps with simulating HKTs):
Higher-Kinded-Jintroduces theKind<F, A>interface. This interface, along with specific "witness types" (likeOptionalKind.Witness,CompletableFutureKind.Witness,EitherKind.Witness<L>), simulates the concept ofF<A>. It allows you to passF(the type constructor, represented by its witness type) as a type parameter, even though Java doesn't support it natively. -
Simplifying Transformer Definition (
EitherT<F, L, R>): Because we can now simulateF<A>usingKind<F, A>, we can define theEitherTdata structure generically:// Simplified from EitherT.java public record EitherT<F, L, R>(@NonNull Kind<F, Either<L, R>> value) implements EitherTKind<F, L, R> { /* ... */ }Here,
Fis a type parameter representing the witness type of the outer monad.EitherTdoesn't need to know which specific monadFis at compile time; it just knows it holds aKind<F, ...>. This makes theEitherTstructure itself general-purpose. -
Simplifying Transformer Operations (
EitherTMonad<F, L>): The real benefit comes with the type class instanceEitherTMonad. This class implementsMonadError<EitherTKind.Witness<F, L>, L>, providing the standard monadic operations (map,flatMap,of,ap,raiseError,handleErrorWith) for the combinedEitherTstructure.Critically,
EitherTMonadtakes theMonad<F>instance for the specific outer monadFas a constructor argument:// From EitherTMonad.java public class EitherTMonad<F, L> implements MonadError<EitherTKind.Witness<F, L>, L> { private final @NonNull Monad<F> outerMonad; // <-- Holds the specific outer monad instance public EitherTMonad(@NonNull Monad<F> outerMonad) { this.outerMonad = Objects.requireNonNull(outerMonad, "Outer Monad instance cannot be null"); } // ... implementation of map, flatMap etc. ... }Inside its
map,flatMap, etc., implementations,EitherTMonaduses the providedouterMonadinstance (via itsmapandflatMapmethods) to handle the outer contextF, while also managing the innerEitherlogic (checking forLeft/Right, applying functions, propagatingLeft). This is where the Higher-Kinded-J drastically simplifies things:
- You only need one
EitherTMonadimplementation. - It works generically for any outer monad
Ffor which you have aMonad<F>instance (likeOptionalMonad,CompletableFutureMonad,IOMonad, etc.). - The complex logic of combining the two monads' behaviours (e.g., how
flatMapshould work onF<Either<L, R>>) is encapsulated withinEitherTMonad, leveraging the simulated HKTs and the providedouterMonadinstance. - As a user, you just instantiate
EitherTMonadwith the appropriate outer monad instance and then use its standard methods (map,flatMap, etc.) on yourEitherTvalues, as seen in theOrderWorkflowRunnerexample. You don't need to manually handle the nesting.
In essence, the HKT simulation provided by Higher-Kinded-J allows defining the structure (EitherT) and the operations (EitherTMonad) generically over the outer monad F, overcoming Java's native limitations and making monad transformers feasible and much less boilerplate-heavy than they would otherwise be.
Further Reading
Start with the Java-focused resources to see practical applications, then explore General FP concepts for deeper understanding, and finally check Related Libraries to see alternative approaches.
Java-Focused Resources
Beginner Level:
- π Error Handling with Either in Java - Baeldung's introduction to Either (10 min read)
- π CompletableFuture Error Handling Patterns - Tomasz Nurkiewicz's comparison to traditional async error handling (15 min read)
- π₯ Railway Oriented Programming in Java - Scott Wlaschin's classic talk adapted to Java contexts (60 min watch)
Intermediate Level:
- π Combining Async and Error Handling in Java - Real-world async error workflows (20 min read)
- π Vavr's Either vs Java's Optional - When to choose what (15 min read)
Advanced:
- π¬ Type-Safe Error Handling at Scale - Zalando's production experience (conference talk, 40 min)
General FP Concepts
- π Railway Oriented Programming - F# for Fun and Profit's accessible explanation (20 min read)
- π Handling Errors Without Exceptions - Chapter 4 from "Functional Programming in Scala" (free excerpt)
- π Either Type - Wikipedia - Formal definition and language comparisons
Related Libraries & Comparisons
- π Vavr Either Documentation - Mature Java FP library's approach
- π Arrow Either - Kotlin's excellent API design
- π Result Type in Rust - See how a systems language solves this problem
Community & Discussion
- π¬ Either vs Exceptions in Java - Stack Overflow debate with practical insights
- π¬ Using Either in Production Java Code - Hacker News discussion with war stories