On Modern Error Handling (Not Just in Java): Monads, Effects and Project Amber - JVM Weekly vol. 81
Since it's a long holiday weekend in Poland, today I have only one topic, although I think it's quite interesting. I suspect that next week we will return to our usual format.
Today will be about approaches to error handling - both in the industry as such and new proposals for Java. And we'll start it all off with Monads.
What is a Monad? Some say it is a monoid in the category of endofunctors, something bolder that buritto. In simple terms, a Monad is a mathematical concept used to model operations that can be composed sequentially, derived from in category theory (a very interesting topic - if you've ever had a pull towards more academic IT, I highly recommend Category Theory For Programmers from Bartosz Milewski). In functional programming, monads are used to handle side effects (like IO operations, or error handling) and data structures in a way that allows concise and flexible management of data flow and operations (again - e.g. error handling).
A common monad used by most of you in Java is the Optional class, which is a way to handle the presence or absence of a value. It essentially wraps a value that may or may not be null, providing methods to safely process it without risking a NullPointerException
. There are many different kinds of Monads, to mention here for example the IO Monad or the Try Monad (which will still return today in our story).
At the same time, this is a term that is very difficult to write about because, as Douglas Crockford, well-known in the JavaScript community, once stated:
In addition to it begin useful, it is also cursed and the curse of the monad is that once you get the epiphany, once you understand - "oh that's what it is" - you lose the ability to explain it to anybody.
I stole the above graphic from the thumbnail of the first of our heroes today - the video The Death of Monads? Direct Style Algebraic Effects, which has been going viral on all sorts of programming aggregators lately and has some interestingly controversial opinions. The author discusses that Monads, while powerful, are sometimes clumsy as a tool for managing side effects in functional programming. The movie argues that direct algebraic effects offer a simpler and cleaner alternative to achieve similar results.... and are much easier to understand.
Imagine you are working on a project that requires the use of various tools and resources, such as files, internet connections or database access. Each of these activities introduces some complications, which in programming we call 'side effects'. Monads are effective at handling them, but they can also be complicated to use and understand because each side-effect step requires careful handling.
public class MonadExample {
public static void main(String[] args) {
Optional<String> email = getUserEmail("123");
// Użycie monady Optional do przetworzenia wartości
email.ifPresent(System.out::println);
}
}
Direct algebraic effects is a newer approach that allows side effects to be managed in a more understandable and flexible way. Instead of surrounding each side effect with a layer of control, as with monads, direct algebraic effects allow them to be declared in a more natural and readable way.
Working with direct algebraic effects allows you to declare side effects as part of the program logic, but their actual execution is deferred to special functions known as 'effect handlers'. These handlers can be customized depending on the context. For instance, different handlers might be used during testing compared to those in production.
Pseudo example of what the whole thing looks like on the logic side:
public class AlgebraicEffectsExample {
public static void main(String[] args) {
String email = perform(getUserEmail("123"));
System.out.println(email);
}
public static String getUserEmail(String userId) throws Effect {
User user = findUserById(userId);
if (user != null) {
return user.getEmail();
} else {
throw new Effect("User not found");
}
}
static class Effect extends Exception {}
}
The effect called in the perform
function could be handled like this:
try {
String email = perform(getUserEmail("123"));
System.out.println(email);
} catch (Effect e) {
handleEffect(e);
}
public static void handleEffect(Effect effect) {
if (effect.getMessage().equals("User not found")) {
System.out.println("No user found with the specified ID.");
} else {
System.out.println("An unknown error occurred.");
}
}
just remember that the following should take place in a way that is invisible to the user, simply Java will not allow implementation. Unfortunately, effects are not something that can be easily simulated and the language must support them 🙁
This approach makes the code cleaner and easier to understand because it separates the programme logic from the direct handling of side effects. It also allows code to be changed and tested more easily, as you can manipulate the way effects are handled without modifying the main program logic. However, as I mentioned, some things are very difficult to simulate (especially in a convenient way that doesn't seem artificial) and Direct algebraic effects are currently natively supported only in languages like Eff, Koka, Multicore OCaml, and Links.
So that's the time to finally go down to the earth and move on to the main part of today's edition. For there have been quite a few interesting JEPs lately (a topic I'll be returning to again), but one of them fits especially nicely into the theme of today's edition. For while algebraic effects are still to come (I wouldn't bet against them becoming available one day, around JDK 50 😉), the Java Developers at some stage considered (and may still be considering) the Try
monad.
The Try
monad is a key in functional programming designed to manage operations that could result in an error. It encapsulates a code execution that might trigger an exception, providing a result as either Success
with a value if the operation is successful, or Failure
with if an error happens. This concept might be recognized from its use in the Vavr library.
import io.vavr.control.Try;
public class TryMonadExample {
public static void main(String[] args) {
Try<Integer> result = Try.of(() -> Integer.parseInt("123"));
result.map(value -> value * 2)
.onSuccess(System.out::println)
.onFailure(ex -> System.out.println("Error occurred: " + ex.getMessage()));
Try<Integer> resultWithFailure = Try.of(() -> Integer.parseInt("abc"));
int recoveredValue = resultWithFailure.getOrElse(0);
}
}
The whole thing is not somehow very difficult to implement (I recommend trying it yourself as a training exercise), but in fact the above solution loses a lot because the language does not have special structures to handle it, making the whole thing very verbose and clunky, with control flow leaking to the business logic. However, the JDK developers with the development of Pattern Matching and the evolution of switch
in Project Amber are looking for a good way to handle exceptions in such, and as a solution in JEP Exception handling in switch (Preview)
It includes improvements on the handling of exceptions thrown by the selector (i.e. e
in switch (e) ...
), which can now be handled directly in the switch
block. Let's demonstrate this through the JEPs use of Future.get()
. To handle different situations, historically we have had to surround switch
with a try-catch block:
Future<Box> f = ...;
try {
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
}
} catch (CancellationException ce) {
...
} catch (ExecutionException ee) {
...
} catch (InterruptedException ie) {
...
}
The introduction of a new exception handling case, written as case throws
, allows exceptions to be captured directly inside the switch
block, eliminating the need for an external try-catch:
Future<Box> f = ...
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
case throws InterruptedException ie -> ...ie...
}
The introduction of case throws
(and null handling) allows switch
to be used as a universal computational machine in expression-based programming contexts, as in API streams. For example:
stream.map(Future<Box> f -> switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws Exception e -> { log(e); return score(0); }
}).reduce(0, Integer::sum);
This approach enables switch
to effortlessly manage both null values and different types of exceptions without the need for extra catch
clauses.
Well, but what does the Try
monad have to do with it? There was a discussion on the JDK mailing list about its further evolution. Indeed, Java developers have considered introducing new constructs such as exception chaining and Try
monads, but opinions are mixed on their necessity. Java currently uses traditional try-catch blocks to manage exceptions, which is straightforward but can be cumbersome when using methods that both return values and throw exceptions - as in the cases described in the text. As try-catch is not an expression (it does not return a value) it makes the code less composable and more error-prone. The proposal to turn try-catch into an expression turned out to be too limited, as it required try and catch blocks to generate the same type, which overly limited the ability to handle different exception results.
The integration of the native Try
monad could offer a more universal solution, embedding exceptions in a function construct that could be processed elsewhere. This idea would allow operations such as queuing or passing the Try
monad to be processed in different contexts, enabling more uniform exception management. However, despite the potential benefits, this approach was also rejected, suggesting that while Java could incorporate more advanced constructs (such as the Try monad with native handling in the language's structures) or extend switch-case constructs for exceptions, the current preference leans towards maintaining or slightly improving existing structures rather than a major overhaul. Indeed, the discussion points to the complexity and potential limitations of introducing new abstractions into the language, which could both improve error handling and at the same time introduce new challenges in exception management.
Baby steps. However, it is evident, however, that Pattern Matching is pushing the evolution of language forward. The whole situation is brought up because it shows once again how important language evolution is ... and how important it is to carry it out carefully in a nonetheless "mainstream" language like Java.
Maybe Effects at some point, though? Loom underneath introduced so-called "delimited continuations" (a mechanism to control the flow of program execution by saving, storing and later restoring specific points in the code, allowing more flexible manipulation of call stacks), and those in Haskell are an intermediate step towards the introduction of the effects system.
And if you enjoyed this kind of consideration of different types and structures, an article a few days ago (perfect timing) entitled Sum types in Java from Ife Sunmola offers an introduction to Java's Sum types (or de facto Union types) along with an example implementation, looking at their usefulness in solving common programming problems that involve managing different but specific types. Treat it as the icing on the cake of this week edition.
It was meant to be short, it came out long - but I hope you enjoyed it.
PS1: As promised in the introduction, next week is a more standard edition. But let me also know if you like such single-topic issues, I'm having a lot of fun with them.
PS2: If you're familiar with Polish language, I'd like to invite you to the javeloper.pl conference on 9 May. At 4pm, I will be talking about GraalVM, specifically Truffle.
As if anyone is considering joining - slides can be found here. You can check them before the presentation.
This was a great read, thanks!!