ErrorContext: Typed Errors in Effectful Code

"You wont never get the whole thing its too big in the nite of your head ther aint room."

— Russell Hoban, Riddley Walker

API calls fail. Database queries timeout. Files go missing. The "whole thing" of what can go wrong in effectful code is indeed too big to hold in your head at once. But you can give those failures names—typed errors that the compiler understands and your code can reason about. ErrorContext captures both the deferred nature of IO and the typed-error semantics of Either in a single, composable abstraction.

What You'll Learn

  • Creating ErrorContexts that catch exceptions and map them to typed errors
  • Chaining dependent computations with via() and flatMap()
  • Recovering from errors with recover(), recoverWith(), and orElse()
  • Transforming error types with mapError()
  • Executing contexts with runIO(), runIOOrThrow(), and runIOOrElse()

The Problem

Traditional Java error handling fragments across styles:

// Style 1: Checked exceptions
public User fetchUser(String id) throws UserNotFoundException { ... }

// Style 2: Unchecked exceptions
public Order createOrder(Cart cart) { /* might throw RuntimeException */ }

// Style 3: Optional returns
public Optional<Profile> getProfile(User user) { ... }

// Style 4: Custom result types
public Result<ValidationError, Order> validateOrder(OrderRequest req) { ... }

Composing across these styles requires constant translation. Each boundary demands explicit handling, cluttering your code with conversion logic.


The Solution

ErrorContext unifies error handling for IO-based operations. Exceptions become typed errors. Optionality can be converted to errors. All computations compose through the same operations:

ErrorContext<IOKind.Witness, OrderError, Order> orderPipeline =
    ErrorContext.<OrderError, User>io(
        () -> userService.fetch(userId),           // May throw
        OrderError::fromException)
    .via(user -> ErrorContext.io(
        () -> orderService.validate(user, request),
        OrderError::fromException))
    .via(validated -> ErrorContext.io(
        () -> orderService.create(validated),
        OrderError::fromException))
    .recover(error -> Order.placeholder(error.message()));

One unified API. One error type. The compiler tracks what can fail.


Creating ErrorContexts

From Throwing Computations

The most common factory method catches exceptions and maps them to your error type:

ErrorContext<IOKind.Witness, ApiError, User> user = ErrorContext.io(
    () -> httpClient.get("/users/" + id),   // May throw
    ApiError::fromException                   // Throwable → ApiError
);

The computation is deferred—nothing executes until you call runIO(). Exceptions thrown during execution are caught and converted to the error type.

From Either-Returning Computations

When your code already returns Either:

ErrorContext<IOKind.Witness, ValidationError, Order> validated = ErrorContext.ioEither(
    () -> validator.validate(request)   // Returns Either<ValidationError, Order>
);

Pure Values

For successful or failed values you already have:

// Known success
ErrorContext<IOKind.Witness, String, Integer> success = ErrorContext.success(42);

// Known failure
ErrorContext<IOKind.Witness, String, Integer> failure = ErrorContext.failure("Not found");

// From an existing Either
Either<ApiError, User> either = lookupUser(id);
ErrorContext<IOKind.Witness, ApiError, User> ctx = ErrorContext.fromEither(either);

Transforming Values

map: Transform the Success Value

map transforms the value inside a successful context:

ErrorContext<IOKind.Witness, String, String> greeting = ErrorContext.success("world");

ErrorContext<IOKind.Witness, String, String> result = greeting.map(s -> "Hello, " + s);
// Success("Hello, world")

ErrorContext<IOKind.Witness, String, String> failed = ErrorContext.failure("oops");
ErrorContext<IOKind.Witness, String, String> stillFailed = failed.map(s -> "Hello, " + s);
// Failure("oops") — map doesn't run on failures

The function only executes if the context is successful. Failures pass through unchanged.


Chaining Computations

via: Chain Dependent Operations

via is the workhorse for sequencing operations where each step depends on the previous:

ErrorContext<IOKind.Witness, DbError, Invoice> invoice =
    ErrorContext.<DbError, Customer>io(
        () -> customerRepo.find(customerId),
        DbError::fromException)
    .via(customer -> ErrorContext.io(
        () -> orderRepo.findByCustomer(customer.id()),
        DbError::fromException))
    .via(orders -> ErrorContext.io(
        () -> invoiceService.generate(orders),
        DbError::fromException));

Each step receives the previous result. If any step fails, subsequent steps are skipped—the failure propagates to the end.

flatMap: Type-Preserving Chain

flatMap is equivalent to via but with a more explicit type signature:

// Using flatMap instead of via
ErrorContext<IOKind.Witness, ApiError, Profile> profile =
    fetchUser(userId)
        .flatMap(user -> fetchProfile(user.profileId()))
        .flatMap(prof -> enrichProfile(prof));

Choose based on readability. Both short-circuit on failure.

then: Sequence Without Using the Value

When you need to sequence but don't care about the previous value:

ErrorContext<IOKind.Witness, String, Unit> workflow =
    ErrorContext.<String, Unit>success(Unit.INSTANCE)
        .then(() -> logAction("Starting"))
        .then(() -> performAction())
        .then(() -> logAction("Completed"));

Recovering from Errors

recover: Provide a Fallback Value

recover catches errors and produces a fallback value:

ErrorContext<IOKind.Witness, String, Config> config =
    ErrorContext.<String, Config>io(
        () -> loadConfigFromServer(),
        Throwable::getMessage)
    .recover(error -> {
        log.warn("Using defaults: {}", error);
        return Config.defaults();
    });

If the original computation fails, the recovery function runs. If it succeeds, recovery is never invoked.

recoverWith: Provide a Fallback Computation

When recovery itself might fail:

ErrorContext<IOKind.Witness, ApiError, Data> data =
    ErrorContext.<ApiError, Data>io(
        () -> primaryService.fetch(),
        ApiError::fromException)
    .recoverWith(error -> ErrorContext.io(
        () -> backupService.fetch(),
        ApiError::fromException));

The fallback is another ErrorContext. This enables fallback chains that can themselves fail.

orElse: Alternative Without Inspecting Error

When you don't need the error details:

ErrorContext<IOKind.Witness, String, User> user =
    fetchFromCache(userId)
        .orElse(() -> fetchFromDatabase(userId))
        .orElse(() -> ErrorContext.success(User.anonymous()));

Transforming Errors

mapError: Change the Error Type

mapError transforms the error without affecting success values:

ErrorContext<IOKind.Witness, String, User> lowLevel = ErrorContext.failure("connection refused");

ErrorContext<IOKind.Witness, ApiError, User> highLevel =
    lowLevel.mapError(msg -> new ApiError(500, msg));

This is essential when composing code from different modules that use different error types.


Execution

runIO: Get an IOPath

runIO() extracts an IOPath<Either<E, A>> for deferred execution:

ErrorContext<IOKind.Witness, ApiError, User> ctx = ...;

// Get the IOPath (nothing runs yet)
IOPath<Either<ApiError, User>> ioPath = ctx.runIO();

// Execute when ready
Either<ApiError, User> result = ioPath.unsafeRun();

runIOOrThrow: Success or Exception

For cases where failure should throw:

try {
    User user = userContext.runIOOrThrow();
    // Use the user
} catch (RuntimeException e) {
    // Error was wrapped: e.getMessage() contains error.toString()
}

runIOOrElse: Success or Default

For cases where failure should use a default:

User user = userContext.runIOOrElse(User.guest());

runIOOrElseGet: Success or Computed Default

When the default depends on the error:

User user = userContext.runIOOrElseGet(error -> {
    log.error("Failed with: {}", error);
    return User.guest();
});

Real-World Patterns

API Client with Error Handling

public class UserClient {
    public ErrorContext<IOKind.Witness, ApiError, User> fetchUser(String id) {
        return ErrorContext.<ApiError, User>io(
            () -> {
                HttpResponse response = httpClient.get("/users/" + id);
                if (response.status() == 404) {
                    throw new NotFoundException("User not found: " + id);
                }
                return parseUser(response.body());
            },
            ApiError::fromException);
    }

    public ErrorContext<IOKind.Witness, ApiError, Profile> fetchProfile(User user) {
        return ErrorContext.<ApiError, Profile>io(
            () -> {
                HttpResponse response = httpClient.get("/profiles/" + user.profileId());
                return parseProfile(response.body());
            },
            ApiError::fromException);
    }
}

// Usage
ErrorContext<IOKind.Witness, ApiError, Profile> profile =
    client.fetchUser(userId)
        .via(client::fetchProfile)
        .recover(error -> Profile.empty());

Multi-Step Transaction

public ErrorContext<IOKind.Witness, OrderError, Order> processOrder(OrderRequest request) {
    return validateRequest(request)
        .via(this::checkInventory)
        .via(this::reserveItems)
        .via(this::chargePayment)
        .via(this::createOrder)
        .recoverWith(error -> {
            // Compensating action
            return rollbackReservations()
                .then(() -> ErrorContext.failure(error));
        });
}

Layered Error Types

// Low-level: database errors
ErrorContext<IOKind.Witness, DbError, User> dbUser =
    ErrorContext.io(() -> db.query("SELECT ..."), DbError::fromSql);

// Mid-level: repository errors
ErrorContext<IOKind.Witness, RepoError, User> repoUser =
    dbUser.mapError(RepoError::fromDbError);

// High-level: API errors
ErrorContext<IOKind.Witness, ApiError, User> apiUser =
    repoUser.mapError(ApiError::fromRepoError);

Escape Hatch

When you need the raw transformer:

ErrorContext<IOKind.Witness, String, Integer> ctx = ErrorContext.success(42);

EitherT<IOKind.Witness, String, Integer> transformer = ctx.toEitherT();

// Now you have full EitherT capabilities

Summary

OperationPurposeShort-circuits on error?
io(supplier, errorMapper)Create from throwing computationN/A
success(value)Create successful contextN/A
failure(error)Create failed contextN/A
map(f)Transform success valueYes
via(f)Chain dependent computationYes
recover(f)Provide fallback valueNo
recoverWith(f)Provide fallback computationNo
mapError(f)Transform error typeNo
runIO()Extract IOPath for executionN/A

ErrorContext brings order to effectful error handling. Exceptions become data. Failures propagate predictably. Recovery is explicit. The shapes in the night become visible.

See Also


Previous: Effect Contexts Next: Optional Contexts