Smuggling Checked Exceptions with Sealed Interfaces

by: Ethan McCue

Vanilla Code

import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

record User(String name) {}

public class VanillaCode {
    public static Optional<User> lookupUser(Connection db, int id) throws SQLException  {
        var statement = db.prepareStatement("SELECT name FROM USER where id = ?");
        statement.setInt(1, id);
        var resultSet = statement.executeQuery();
        if (resultSet.next()) {
            return Optional.of(new User(resultSet.getString(1)));
        }
        else {
            return Optional.empty();
        }
    }

    public static List<Optional<User>> lookupMultipleUsers(Connection db, List<Integer> ids) throws SQLException {
        List<Optional<User>> users = new ArrayList<>();
        for (int id : ids) {
            users.add(lookupUser(db, id));
        }
        return users;
    }

    public static void exampleUsage(Connection db) {
        try {
            lookupUser(db, 123).ifPresentOrElse(
                    user -> System.out.println("FOUND USER: " + user),
                    () -> System.out.println("NO SUCH USER")
            );
        } catch (SQLException sqlException) {
            System.out.println("ERROR RUNNING QUERY: " + sqlException);
        }
    }
}

Sealed Types

import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;

record User(String name) {}

sealed interface UserLookupResult {
    record FoundUser(User user) implements UserLookupResult {}
    record NoSuchUser() implements UserLookupResult {}
    record ErrorRunningQuery(SQLException sqlException) implements UserLookupResult {}
}

public class SealedTypes {
    public static UserLookupResult lookupUser(Connection db, int id) {
        try {
            var statement = db.prepareStatement("SELECT name FROM USER where id = ?");
            statement.setInt(1, id);
            var resultSet = statement.executeQuery();
            if (resultSet.next()) {
                return new UserLookupResult.FoundUser(new User(resultSet.getString(1)));
            }
            else {
                return new UserLookupResult.NoSuchUser();
            }
        }
        catch (SQLException e) {
            return new UserLookupResult.ErrorRunningQuery(e);
        }
    }

    public static List<UserLookupResult> lookupMultipleUsers(Connection db, List<Integer> ids) {
        return ids
                .stream()
                .map(id -> lookupUser(db, id))
                .toList();
    }

    public static void exampleUsage(Connection db) {
        switch (lookupUser(db, 123)) {
            case UserLookupResult.FoundUser foundUser ->
                System.out.println("FOUND USER: " + foundUser.user());
            case UserLookupResult.NoSuchUser __ ->
                System.out.println("NO SUCH USER");
            case UserLookupResult.ErrorRunningQuery errorRunningQuery ->
                System.out.println("ERROR RUNNING QUERY: " + errorRunningQuery.sqlException());
        }
    }
}

Sealed interfaces let you properly represent "sum types". This thing is either "A" or "B".

In this case we have a Stream<Integer> that we want to turn into a Stream<User>, but our method that takes Integer -> User throws a SQLException.

Your options before sealed interfaces were to

  1. Make it a Stream<User> by re-throwing any SQLException as a RuntimeException. As other comments have mentioned, this can be an issue depending on your type of stream. Also it requires that you fail the whole procedure if getting any one User fails
  2. Make it a Stream<Object> by returning any SQLException as a value, then cast it back at the end with instanceof checks.
  3. Make it a Stream<Try<User>> like with vavr. This works if you don't care about the exception and just care that it failed in some way, but you won't be able to call methods particular to SQLException like getSQLState without casting. Also you lose documentation of why something can fail.
  4. Do this same technique, but without a sealed interface. Doing this without needing to have default -> ... error ... branches on all your switches would require using the visitor pattern.

The new option is what you see, you can properly represent a function from Integer -> User | SQLException by wrapping each of the values in a sealed hierarchy.

sealed interface UserLookupResult {
    record FoundUser(User user) implements UserLookupResult {}
    record ErrorRunningQuery(SQLException sqlException) implements UserLookupResult {}
}

So it becomes Stream<Integer> -> Stream<UserLookupResult>, which is effectively Stream<FoundUser | ErrorRunningQuery>. This gives you the most flexibility in how to interpret the result of the Stream without any casting or assumptions in usage.

Also if you had another possibility like UserIsBanned, you can add it to the sealed hierarchy and all our switches would force you to fix them.


<- Index