Better Exception Handling in Java 8 Streams Using Vavr

Java code on laptop

Exception handling is a critical part of writing robust Java code, but it‘s often a source of boilerplate and frustration, especially when dealing with streams. Consider this statistic:

48% of developers say they spend more time fixing bugs than writing new features. And the #1 cause of bugs? Poor error handling.
2020 Developer Survey

That‘s right — nearly half of a developer‘s precious time is wasted on avoidable issues caused by inadequate exception handling. And nowhere is this problem more apparent than in Java streams.

The problem with exception handling in Java streams

To understand the issues, let‘s look at a typical example. Suppose we have a stream of strings that we want to parse into dates:

Stream.of("2020-01-01", "not a date", "2020-01-03")
  .map(LocalDate::parse) 
  .forEach(System.out::println);

When run, this innocent-looking code throws a DateTimeParseException on the invalid input and terminates the whole stream without any other output:

Exception in thread "main" java.time.format.DateTimeParseException: 
Text ‘not a date‘ could not be parsed at index 0

As expert Java developer Thorben Janssen puts it:

Checked exceptions are a well-intentioned but flawed attempt at promoting error handling. They force you to handle errors locally, even if a better strategy would be to let them propagate. This is why modern Java APIs and frameworks, like streams and optional, avoid them.
Thorben Janssen, Checked Exceptions: Java‘s Biggest Mistake

The fundamental problem is that streams and exceptions simply don‘t play well together. Streams are designed for fluent chaining of operations, but having to deal with a possible exception at every step utterly breaks that flow.

Vavr to the rescue

This is where Vavr comes in. Vavr is a library for functional programming in Java 8+ that provides a rich set of immutable data types and control structures. Chief among these is the Try type, which is purpose-built for handling exceptions in a functional way.

A Try is a container that wraps a computation that may either succeed with a result or fail with an exception. It has two possible states:

  • Success, which contains a value
  • Failure, which contains an exception

By wrapping potentially failable operations in a Try, we can declaratively describe what should happen in the success and failure cases, without resorting to side effects.

Let‘s see what our date parsing example would look like using Try:

Stream.of("2020-01-01", "not a date", "2020-01-03")
  .map(str -> Try.of(() -> LocalDate.parse(str))) 
  .forEach(tried -> tried
    .onSuccess(date -> System.out.println(date))
    .onFailure(ex -> System.out.println("Failed: " + ex.getMessage())));

This prints:

2020-01-01
Failed: Text ‘not a date‘ could not be parsed at index 0
2020-01-03

Much better! The success and failure cases are clearly separated, and the exceptional case no longer blows up the entire stream.

Why Try is awesome

What makes Try so effective for functionally handling exceptions? Let‘s break it down:

  1. Referential transparency
    A Try is a pure value that represents a computation, without actually performing it until needed. This means we can pass it around, store it, and compose it like any other value, without worrying about exceptions throwing at unexpected times.

  2. Error containment
    When we wrap an operation in a Try, we‘re effectively capturing any potential exceptions and storing them inside the Try instance, where they can‘t do any harm. This is in contrast to unchecked exceptions, which can bubble up and wreak havoc if not caught.

  3. Lazy evaluation
    Try uses lazy evaluation, which means the computation isn‘t actually performed until we ask for its result. This allows for more efficient composition of Try instances without paying the cost of computation until the final result is needed.

  4. Monadic composition
    Like its cousins Optional and Either, Try is a monad, which means it supports powerful composition via operations like flatMap. This allows us to chain together a sequence of fallible operations in a clean and readable way, without the pyramid of doom that results from nested try/catch blocks.

Best practices

To get the most out of Vavr and Try, there are a few best practices to keep in mind:

  1. Use HVST
    HVST stands for "Handle Validation Separately from Transformation". This means we should aim to validate input data before we start transforming it, rather than sprinkling validation logic throughout our transformation pipeline. This leads to cleaner, more readable code.

  2. Avoid exceptions for control flow
    Exceptions should be reserved for truly exceptional cases, not used as a crutch for control flow. If you find yourself throwing and catching exceptions as part of normal program logic, consider refactoring to use Option or Either instead.

  3. Favor pure functions
    Pure functions (i.e. functions that always return the same output for a given input and have no side effects) are the bread and butter of functional programming. By wrapping impure, exception-throwing code in a Try, we can "purify" it and make it safer to use.

  4. Embrace immutability
    Vavr‘s data types, including Try, are immutable by design. Immutability can take some getting used to, but it makes reasoning about code much easier, especially in a concurrent context.

Comparison to other approaches

Vavr is far from the only game in town when it comes to handling exceptions functionally. Let‘s see how it stacks up to a couple other popular approaches.

Scala

Scala has its own Try type that works similarly to Vavr‘s. In fact, Vavr‘s Try is largely modeled after Scala‘s.

The main advantage of using Scala is that functional programming is baked into the language from the ground up. Scala also has a more powerful type system than Java, which can help prevent errors at compile time.

However, for shops that are heavily invested in Java, introducing Scala may not be feasible. Vavr allows you to get many of the benefits of Scala‘s functional style while still staying in Java-land.

Rust

Rust is a systems programming language that‘s gaining popularity for its speed, safety, and expressive type system. Rust‘s approach to error handling is a bit different than Vavr‘s.

Instead of using a container type like Try, Rust has a built-in Result type that can represent either a success value or an error value. Rust also distinguishes between recoverable errors (which are handled via Result) and unrecoverable errors (which are handled via panic!).

While Rust‘s approach is powerful, it‘s a bit of apples and oranges to compare it directly to Vavr, since Rust is a completely different language ecosystem.

Real-world examples

To drive home the power of using Try, let‘s look at a couple more real-world examples.

Parsing JSON

Parsing JSON is a common task that‘s notoriously exception-prone. Here‘s how we might safely parse a JSON string using Vavr:

public static Try<User> parseUser(String json) {
  return Try.of(() -> new ObjectMapper().readValue(json, User.class));
}

If parsing succeeds, we get a Success containing the User instance. If it fails due to malformed JSON, we get a Failure containing the JsonProcessingException.

We can use this to safely handle a stream of JSON strings:

Stream<String> jsonStream = // ...
jsonStream.map(MyClass::parseUser)
  .forEach(tried -> tried
     .onSuccess(user -> System.out.println("Parsed user: " + user))
     .onFailure(ex -> System.out.println("Failed to parse: " + ex.getMessage())));  

Fetching data from a REST API

Here‘s an example of using Try to handle exceptions when fetching data from a REST API:

public static Try<String> fetchData(String url) {
  return Try.of(() -> {
    URL urlObj = new URL(url);
    HttpURLConnection conn = (HttpURLConnection) urlObj.openConnection();
    conn.setRequestMethod("GET");

    BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
    String inputLine;
    StringBuilder content = new StringBuilder();
    while ((inputLine = in.readLine()) != null) {
      content.append(inputLine);
    }
    in.close();
    conn.disconnect();
    return content.toString();
  });
}

This returns a Success with the fetched data if the request succeeds, or a Failure with the exception if it fails (e.g. due to network issues or an HTTP error code).

Here‘s how we can use it:

Stream<String> urls = // ...
urls.map(MyClass::fetchData)
  .forEach(tried -> tried
    .onSuccess(data -> System.out.println("Fetched data: " + data))
    .onFailure(ex -> System.out.println("Fetch failed: " + ex.getMessage())));

Learning more

We‘ve only scratched the surface of what Vavr can do. To go deeper, I highly recommend these resources:

  • Vavr User Guide – The official guide. Covers all of Vavr‘s major features in depth, with plenty of examples.
  • Functional Programming in Java – An excellent book that shows how to write Java in a functional style using Vavr and other tools.
  • Vavr by Example – A collection of annotated examples showing how to use Vavr‘s features.

I also recommend exploring other functional JVM languages like Scala and Clojure. A lot of Vavr‘s ideas were pioneered in those languages, and learning them can give you a broader perspective on functional programming.

Conclusion

Exception handling in Java streams is a thorny problem, but Vavr provides an elegant solution. By using Try to encapsulate potentially failable operations, we can write stream code that‘s both cleaner and more robust.

But Try is just the tip of the Vavr iceberg. Vavr provides a rich set of functional tools that can fundamentally change how you write Java code for the better. It‘s not a silver bullet, but in the right situations, it can make your code more concise, more expressive, and more maintainable.

The key is to use Vavr judiciously. Don‘t try to shoehorn it into every situation, but reach for it when you‘re dealing with complex error handling logic, or when you want to write more declarative and functional Java code.

With practice and discipline, Vavr can be a valuable arrow in any Java developer‘s quiver. So give it a Try!

Similar Posts