Skip to content

Either Monad

Vasilis Soumakis edited this page Jul 21, 2024 · 1 revision

Either Monad

The Either monad represents a value of one of two possible types, making it a useful tool for handling situations where a value can be one of two different types, typically representing a success or an error condition. Instances of Either are usually represented as Left and Right, where conventionally Left represents an error or exceptional case and Right represents the successful result.

Definition of Either Monad

In java the Either Monad is generally defined as

public sealed interface Either<L, R> permits Left, Right

public record Left<L, R>(L value) implements Either<L, R> {

}

public record Right<L, R>(R value) implements Either<L, R> {

}

The primary advantage of using Either is that it enforces explicit handling of both success and error cases, leading to more robust and error-resistant code.

Problems with exceptions

  1. Methods that throw exceptions, particularly RuntimeException(s), do not indicate this in their type signatures. This makes it unclear in which cases the method can fail.
  2. In large codebases with multiple contributors, it's easy to forget to catch specific exceptions, potentially leading to runtime errors, application crashes, or unintended states.
  3. Chaining operations with exceptions often requires verbose try-catch blocks, which can make the code harder to read and maintain.
  4. Exceptions can disrupt the normal control flow of a program, making it harder to follow the logic and leading to complex and difficult-to-maintain code.
  5. Exceptions can be caught and rethrown at different layers of the codebase, potentially losing context and making it difficult to trace the source of the error.

Advantages of using the Either Monad

  1. Explicit Type Signatures: The type signature of functions using Either explicitly shows that the function can return either a successful result or an error, making the function's behavior more predictable and understandable.
  2. Enforced Error Handling: Either forces the developer to handle both success and error cases explicitly.
  3. Integration with Functional Programming Constructs: Either works well with functional programming constructs like map, flatMap, and for-comprehensions, allowing for more fluent chaining of operations that may fail.
  4. Immutability: Values of Either are immutable, simplifying reasoning about the code and avoiding side effects.

Use cases

Consider a URL shortener service that generates a short URL from a long URL and stores it in DynamoDB. Here's an example of how we might handle errors traditionally:

public String save(String hash, String originalUrl, LocalDateTime createdAt, LocalDateTime expiresAt) {
    Map<String, AttributeValue> item = new HashMap<>();
    item.put(URL_HASH, createStringAttribute(hash));
    item.put(ORIGINAL_URL, createStringAttribute(originalUrl));
    item.put(CREATED_AT, createNumberAttribute(createdAt));
    item.put(EXPIRES_AT, createNumberAttribute(expiresAt));

    try {
        dynamoDbClient.putItem(constructPutItemRequest(item));
        log.info("Saved item to DynamoDB with hash {}", hash);
        return hash; // Success path returns directly
    } catch (ConditionalCheckFailedException e) {
        log.error("Failed to save to DynamoDB with hash {}", hash, e);
        throw new IllegalStateException("Hash already exists", e);
    } catch (Exception e) {
        log.error("Failed to save to DynamoDB with hash {}", hash, e);
        throw new IllegalStateException("Generic error occurred", e);
    }
}

Limitations:

  1. Verbosity: The code is verbose due to multiple catch blocks.
  2. Unspecified Exceptions: The method signature does not specify the exceptions being thrown. This can be discovered through documentation or by examining the implementation.
  3. Lack of Context: All exceptions are of type IllegalStateException, which may obscure the specific error context.

We can improve error handling by creating our own domain exceptions with an enum declared stating the different error states.

public enum ErrorState {
    HASH_ALREADY_EXISTS,
    GENERIC_ERROR
}

public class UrlShortenerException extends RuntimeException {
    private final ErrorState errorState;

    public UrlShortenerException(String message, ErrorState errorState) {
        super(message);
        this.errorState = errorState;
    }

    public ErrorState getErrorState() {
        return errorState;
    }
}

and adapt the method as follows:

public String save(String hash, String originalUrl, LocalDateTime createdAt, LocalDateTime expiresAt) {
    Map<String, AttributeValue> item = new HashMap<>();
    item.put(URL_HASH, createStringAttribute(hash));
    item.put(ORIGINAL_URL, createStringAttribute(originalUrl));
    item.put(CREATED_AT, createNumberAttribute(createdAt));
    item.put(EXPIRES_AT, createNumberAttribute(expiresAt));

    try {
        dynamoDbClient.putItem(constructPutItemRequest(item));
        log.info("Saved item to DynamoDB with hash {}", hash);
        return hash;
    } catch (ConditionalCheckFailedException e) {
        log.error("Failed to save to DynamoDB with hash {}", hash, e);
        throw new UrlShortenerException("Hash already exists", ErrorState.HASH_ALREADY_EXISTS);
    } catch (Exception e) {
        log.error("Failed to save to DynamoDB with hash {}", hash, e);
        throw new UrlShortenerException("Generic error occurred", ErrorState.GENERIC_ERROR);
    }
}

Limitations:

  1. Unspecified Error States: The method signature still does not specify the possible error states.
  2. Client Responsibility: The client is responsible for catching and handling exceptions, and future changes to error states may not be reflected in the method signature.

Improved Approach with Either Monad

To leverage Either, where Left represents the error state and Right represents the successful result:

public Either<UrlShortenerError, String> save(
      String hash, String originalUrl, LocalDateTime createdAt, LocalDateTime expiresAt) {
    Map<String, AttributeValue> item = new HashMap<>();
    item.put(URL_HASH, createStringAttribute(hash));
    item.put(ORIGINAL_URL, createStringAttribute(originalUrl));
    item.put(CREATED_AT, createNumberAttribute(createdAt));
    item.put(EXPIRES_AT, createNumberAttribute(expiresAt));

    return Try.of(() -> dynamoDbClient.putItem(constructPutItemRequest(item)))
        .peek(throwable -> log.error("Failed to save to DynamoDB with hash {}", hash, throwable),
            success -> log.info("Saved item to DynamoDB with hash {}", hash))
        .fold(throwable -> handleError(throwable, hash), savedHash -> Either.right(hash));
  }

private Either<UrlShortenerError, String> handleError(Throwable throwable, String hash) {
    log.error("Failed to save to DynamoDB with hash {}", hash, throwable);
    if (throwable instanceof ConditionalCheckFailedException) {
      return Either.left(UrlShortenerError.HASH_ALREADY_EXISTS);
    } else {
      return Either.left(UrlShortenerError.GENERIC_ERROR);
    }
  }

Advantages:

  1. Explicit Return Type: The method signature explicitly states that the outcome can be either a success (with the saved hash) or an error (with an error state).
  2. Immutable Operations: Functional operations like fold and map are immutable, simplifying code.
  3. Avoids Exceptions: Reduces reliance on exceptions, which are often costly.
  4. Improved Debugging: Enhances the ability to reason about and debug the code, especially in complex scenarios.

Using pattern matching or conditional checks, we can handle results clearly:

var result = save("someHash", "someoriginalurl", now, then);

switch (result) {
    case Either.Left(ErrorState err) && err == ErrorState.HASH_ALREADY_EXISTS -> {
        // Handle HASH_ALREADY_EXISTS error
        System.out.println("Error: Hash already exists.");
    }
    case Either.Left(ErrorState err) -> {
        // Handle generic error
        System.out.println("Error: " + err);
    }
    case Either.Right(String originalUrl) -> {
        // Handle success, originalUrl contains the URL
        System.out.println("Success: Original URL is " + originalUrl);
    }
}

There is no need for a default expression and we also can rely on the compiler to warn in case the calling method enriches the ErrorState.