EitherT: Combining Monadic Effects
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>
. F
andL
are fixed for a specificEitherT
context, whileR
is the variable type parameterA
inKind<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.widen
andEITHER_T.narrow
internally to manage the conversion between theKind
and 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 anEitherT
representingF<Left(l)>
, andhandleErrorWith
allows 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 valueA
into theEitherT
context. Result:F<Right(A)>
.eitherTMonad.map(f, eitherTKind)
: Applies functionA -> B
to theRight
value inside the nested structure, preserving bothF
andEither
contexts (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 theF
context, 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 theF
contexts and theEither
logic.
- If
eitherTMonad.raiseError(errorL)
: Creates anEitherT
representing a failure in the innerEither
. Result:F<Left(L)>
.eitherTMonad.handleErrorWith(eitherTKind, handler)
: Handles a failureL
from 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
EitherTMonad
with the outerCompletableFutureMonad
. - Lifting the initial value using
eitherTMonad.of
. - Using
eitherTMonad.flatMap
to sequence steps. - Lifting a synchronous
Either
result intoEitherT
usingEitherT.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
EitherT
using.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>
whereF
itself represents any container type (likeList<_>
) andA
is the value type. This limitation makes defining general monad transformers rather difficult. A monad transformer likeEitherT
needs to combine an arbitrary outer monadF
with the innerEither
monad. 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-J
introduces 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 theEitherT
data 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,
F
is a type parameter representing the witness type of the outer monad.EitherT
doesn't need to know which specific monadF
is at compile time; it just knows it holds aKind<F, ...>
. This makes theEitherT
structure 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 combinedEitherT
structure.Critically,
EitherTMonad
takes theMonad<F>
instance for the specific outer monadF
as 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,EitherTMonad
uses the providedouterMonad
instance (via itsmap
andflatMap
methods) to handle the outer contextF
, while also managing the innerEither
logic (checking forLeft
/Right
, applying functions, propagatingLeft
). This is where the Higher-Kinded-J drastically simplifies things:
- You only need one
EitherTMonad
implementation. - It works generically for any outer monad
F
for which you have aMonad<F>
instance (likeOptionalMonad
,CompletableFutureMonad
,IOMonad
, etc.). - The complex logic of combining the two monads' behaviors (e.g., how
flatMap
should work onF<Either<L, R>>
) is encapsulated withinEitherTMonad
, leveraging the simulated HKTs and the providedouterMonad
instance. - As a user, you just instantiate
EitherTMonad
with the appropriate outer monad instance and then use its standard methods (map
,flatMap
, etc.) on yourEitherT
values, as seen in theOrderWorkflowRunner
example. 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.