Java Exceptions can be used to express errors that block the execution flow of an application. They differ between unchecked and checked Exceptions with the latter requiring a try/catch to handle them. This behaviour makes execution flow harder to understand as it provides a secondary flow that needs to be reasoned about when reading the code. Therefore the recommendation is to only use Exceptions when it is actually required with the rule of thumb being:
Exceptions are for exceptional behaviour
Errors as types is a common pattern for mitigating the need for Exceptions. Imagine the Collection<E> function boolean add(E e) in the standard library. This returns “true if this collection changed as a result of the call”. Here a possible error case is expressed in the type system. A caller of the add-function can choose how they handle this scenario.
A different example is the standard library’s Stream<T> with the Optional<T> findFirst() “returns an Optional describing the first element of this stream, or an empty Optional if the stream is empty.” Here the error is more obvious as calling a findFirst function you would expect a first missing to be an error. But the return type encapsulates the first missing, so no Exception has to be thrown.
Both of these examples contain scenarios that can throw. For example add(null) could still throw a NullPointerException. Not all error cases are expressed in the type, but the most common are.
Consider an e-commerce application that wants to check order details before confirming:
Optional<Order> confirm(OrderDetails details) {
if (isOutOfStock(details)) {
return Optional.empty();
}
if (isAddressOutOfRange(details)) {
return Optional.empty();
}
if (isPaymentInvalid(details)) {
return Optional.empty();
}
final var order = new Order(details);
return Optional.of(order);
}
In this case the Optional<Order> holds the information about whether it was confirmed or not. This provides an easy interface for both checking if an order is possible, but also testing the requirements. The problem in both of these cases is that it is not possible to differentiate the rejection outcomes. In order to both give the user more accurate information and test that the different rejection scenarios can happen, the type needs to include the rejection scenario. This can be done by using a Result/Either pattern:
sealed interface OrderResult {
record Accepted(Order order) implements OrderResult {}
record Rejected(OrderRejection reason) implements OrderResult {}
}
enum OrderRejection {
OUT_OF_STOCK,
INVALID_PAYMENT,
ADDRESS_OUT_OF_RANGE
}
OrderResult confirm(OrderDetails details) {
if (isOutOfStock(details)) {
return new Rejected(OUT_OF_STOCK);
}
if (isAddressOutOfRange(details)) {
return new Rejected(ADDRESS_OUT_OF_RANGE);
}
if (isPaymentInvalid(details)) {
return new Rejected(INVALID_PAYMENT);
}
final var order = new Order(details);
return new Accepted(order);
}
Here the OrderResult can be either an Accepted or Rejected order, and the Rejected order can be checked for a reason:
switch (result) {
case Accepted(Order order) -> fulfillOrder(order);
case Rejected(OrderRejection reason) -> showErrorToCustomer(reason);
}
This provides a convenient way to model the error scenarios where you can delay the decision of how the error should be handled to later. You can expose the decision to the consumer of an API instead of making the choice for them. Overall a powerful pattern to model type-rich applications and libraries.