DEV Community

Sanjeet Singh Jagdev
Sanjeet Singh Jagdev

Posted on

Designing an Either<L, R> in Java: Making Failure Part of the Type System

Inspiration

Error handling in Java is powerful — but it is also architectural.

In small programs, exceptions are straightforward. In larger systems, they quietly influence structure: where logic lives, how layers depend on each other, and how responsibilities are divided.

While refactoring some database initialization code, I noticed something uncomfortable: most of my try/catch blocks weren’t protecting against rare failures. They were handling completely expected outcomes — missing configuration, invalid values, connection problems.

Those aren’t exceptional situations. They are part of the domain.

That observation led me to experiment with a minimal Either<L, R> container — not only as a functional programming exercise, but also as a design exploration:

What happens when failure becomes data instead of control flow?

This article walks through:

  • The architectural problem with nested exception handling
  • Designing a minimal Either<L, R>
  • Refactoring real code to use it
  • The trade-offs this introduces

An obvious question would be, "Why not use Vavr?"

Short answer, We can! But then I also wanted to learn how to make composable containers.

Problem: Exceptions as Hidden Control Flow

Consider a simplified database setup workflow:

  • Read configuration from file
  • Parse configuration
  • Connect to database
  • Execute schema
  • Insert data
public class DbTestImperative {

    public static void main(String[] args) {
        performDatabaseOperation();
    }

    public static void performDatabaseOperation() {
        try {
            Properties props = readDatabaseConfig("db_config.properties");

            String url = props.getProperty("db.url");
            String user = props.getProperty("db.user");
            String password = props.getProperty("db.password");

            try (
                    Connection conn = DriverManager.getConnection(url, user, password);
                    Statement stmt = conn.createStatement()
            ) {
                String schema = readTableSchema("schema.sql");
                stmt.execute(schema);
                stmt.executeUpdate("INSERT INTO users (name, email) VALUES ('John', 'john@example.com')");
            }
        } catch (IOException e) {
            System.err.println("Configuration error: " + e.getMessage());
        } catch (SQLException e) {
            System.err.println("Database error: " + e.getMessage());
        }
    }

    public static String readTableSchema(String fileName) throws IOException {
        try (
                InputStream in = DbTestImperative.class
                        .getClassLoader()
                        .getResourceAsStream("schema.sql")
        ) {
            if (in == null) {
                throw new IOException("schema.sql not found");
            }
            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
        }
    }

    public static Properties readDatabaseConfig(String fileName) throws IOException {
        Properties props = new Properties();
        try (InputStream in = DbTestImperative.class.getClassLoader().getResourceAsStream(fileName)) {
            props.load(in);
            System.out.println("Configuration file read successfully.");
        }
        return props;
    }
}
Enter fullscreen mode Exit fullscreen mode

This code works. But architecturally, several things are happening:

  • Error propagation is implicit.
  • Business logic and error handling are interleaved.
  • Each layer must decide whether to catch, wrap, or rethrow.
  • Method signatures do not communicate failure modes clearly.

In this flow, an IOException is not an anomaly. It is part of the normal behavior of the system. The type system, however, does not reflect that.

The result is control flow that is partially hidden in exception mechanics.

Modeling Failures Explicitly

Instead of throwing exceptions, what if methods returned:

Either<AppError, DbConfig>
Enter fullscreen mode Exit fullscreen mode

That type says:

  • Left → Something went wrong (AppError)
  • Right → Success (DbConfig)

Failure is no longer an implicit side effect. It is a first-class value.

The caller cannot ignore it. The method signature forces acknowledgment.

Designing a Minimal Either<L, R>

The goal was not to build a full functional library, but to design the smallest useful container that supports:

  • Mapping over success
  • Chaining fallible operations
  • Explicit boundary handling

This is not meant to compete with libraries such as Vavr. The purpose here is architectural clarity — understanding the mechanics by building the abstraction ourselves.

public interface Either<L, R> {
    boolean isLeft();
    boolean isRight();

    L getLeft();
    R getRight();

    Optional<L> left();
    Optional<R> right();

    static <L, R> Either<L, R> left(L value) {
        return new Left<>(value);
    }

    static <L, R> Either<L, R> right(R value) {
        return new Right<>(value);
    }

    @SuppressWarnings("unchecked")
    default <L2> Either<L2, R> mapLeft(Function<L, L2> mapper) {
        Objects.requireNonNull(mapper);
        return isLeft() ? left(mapper.apply(getLeft())) : (Either<L2, R>) this;
    }

    @SuppressWarnings("unchecked")
    default <R2> Either<L, R2> mapRight(Function<R, R2> mapper) {
        Objects.requireNonNull(mapper);
        return isRight() ? right(mapper.apply(getRight())) : (Either<L, R2>) this;
    }

    @SuppressWarnings("unchecked")
    default <L2> Either<L2, R> flatMapLeft(Function<L, Either<L2, R>> mapper) {
        return isLeft() ? mapper.apply(getLeft()) : (Either<L2, R>) this;
    }

    @SuppressWarnings("unchecked")
    default <R2> Either<L, R2> flatMapRight(Function<R, Either<L, R2>> mapper) {
        return isRight() ? mapper.apply(getRight()) : (Either<L, R2>) this;
    }

    default <L2, R2> Either<L2, R2> biMap(Function<L, L2> leftMapper, Function<R, R2> rightMapper) {
        Objects.requireNonNull(leftMapper);
        Objects.requireNonNull(rightMapper);

        return isRight() ?
                right(rightMapper.apply(getRight())) :
                left(leftMapper.apply(getLeft()));
    }

    default <T> T fold(Function<L, T> leftFold, Function<R, T> rightFold) {
        Objects.requireNonNull(leftFold);
        Objects.requireNonNull(rightFold);

        return isRight() ?
                rightFold.apply(getRight()) :
                leftFold.apply(getLeft());
    }

    default void fold(Consumer<L> leftFold, Consumer<R> rightFold) {
        Objects.requireNonNull(leftFold);
        Objects.requireNonNull(rightFold);

        if (isRight()) rightFold.accept(getRight());
        else leftFold.accept(getLeft());
    }

    final class Left<L, R> implements Either<L, R> {
        private final L value;

        public Left(L value) { this.value = value; }

        public boolean isLeft() { return true; }
        public boolean isRight() { return false; }
        public L getLeft() { return value; }
        public R getRight() { throw new NoSuchElementException("getRight() on Left<L, R>"); }
        public Optional<L> left() { return Optional.ofNullable(value); }
        public Optional<R> right() { return Optional.empty(); }
    }

    final class Right<L, R> implements Either<L, R> {
        private final R value;

        public Right(R value) { this.value = value;}

        public boolean isLeft() { return false; }
        public boolean isRight() { return true; }
        public L getLeft() { throw new NoSuchElementException("getLeft() on Right<L, R>"); }
        public R getRight() { return value; }
        public Optional<L> left() { return Optional.empty(); }
        public Optional<R> right() { return Optional.ofNullable(value); }
    }
}
Enter fullscreen mode Exit fullscreen mode

Three operations carry most of the weight:

  • mapRight → transform success
  • flatMapRight → chain fallible operations
  • fold → exit the abstraction

The rest supports ergonomics and symmetry.

Refactoring the First Layer: Configuration

Instead of throwing IOException, we return Either.

public static Either<AppError, Properties> readDatabaseConfig(String fileName) {

    Properties props = new Properties();

    try (InputStream in = DbTestEither.class
            .getClassLoader()
            .getResourceAsStream(fileName)) {

        if (in == null)
            return Either.left(new AppError(fileName + " not found"));

        props.load(in);
        return Either.right(props);

    } catch (IOException e) {
        return Either.left(new AppError(e.getMessage()));
    }
}
Enter fullscreen mode Exit fullscreen mode

The method now communicates explicitly:

  • It may fail.
  • Failure is part of its contract.
  • Callers must handle it.

No checked exceptions. No hidden propagation.

Adding Validation as Composition

Parsing configuration is another fallible step.

public static Either<AppError, DbConfig> parseConfig(Properties properties) {

    String url = properties.getProperty("db.url");
    String user = properties.getProperty("db.user");
    String password = properties.getProperty("db.password");

    if (url == null || url.isBlank())
        return Either.left(new AppError("db.url is not provided"));

    if (user == null || user.isBlank())
        return Either.left(new AppError("db.user is not provided"));

    if (password == null || password.isBlank())
        return Either.left(new AppError("db.password is not provided"));

    return Either.right(new DbConfig(url, user, password));
}
Enter fullscreen mode Exit fullscreen mode

Composition becomes straightforward:

public static Either<AppError, DbConfig> loadConfig(String fileName) {
    return readDatabaseConfig(fileName)
            .flatMapRight(DbTestEither::parseConfig);
}
Enter fullscreen mode Exit fullscreen mode

This is where the architectural shift happens.

  • If reading fails → the error propagates.
  • If parsing fails → the error propagates.
  • If both succeed → we get DbConfig.

No nested conditionals.
No rethrowing.
No duplicated catch logic.

There is no branching logic here. The control flow is encoded in the type.

Handling at the System Boundary

Eventually, you must handle the result. That’s what fold is for:

public static void performDatabaseOperation() {
    loadConfig("db_config.properties").fold(
                err -> System.err.println("Error Occurred: " + err.errorMsg),
                DbTestEither::insertRecord
        );
}
Enter fullscreen mode Exit fullscreen mode

Architecturally, this separation matters.

Inside the system, logic remains composable and side-effect free.

At the boundary, you translate values into effects: logging, retries, termination.

That distinction scales well.

Architectural Implications

Using Either changes design in subtle but important ways:

  1. Failure is part of the type system. Method signatures now document failure explicitly.
  2. Control flow becomes declarative. `flatMapRight` encodes propagation rules structurally.
  3. Boundaries become clear, fold marks the edge between pure logic and side effects.
  4. Exceptions become truly Exceptional, you reserve exceptions for unrecoverable system failures.

Trade-offs

This approach is not universally superior.

You lose:

  • Automatic stack trace propagation unless you explicitly model it
  • Simplicity for very small programs
  • Immediate familiarity for some teams

You gain:

  • Explicit failure modeling
  • Better composability
  • Clearer architectural boundaries
  • Improved reasoning about control flow

For business logic, validation, parsing, and external calls, this model scales better than nested exceptions.

Closing Thoughts

Designing this minimal Either wasn’t about importing functional programming into Java.

It was about making failure visible.

When failure becomes data instead of control flow:

  • Pipelines become natural.
  • Types tell the story.
  • Architecture becomes clearer.

And that changes how you structure systems.

You can find the entire code for this article in this Github Gist

Top comments (0)