Java without exceptions

Pattern matching and records have made error handling with algebraic types in Java much more ergonomic. If you find yourself tired of chasing exceptions, rolling your own Result may actually make your code feel more functional, more explicit, and surprisingly more readable.

The Result type is like Optional, but represents success or error rather than presence or absence of a value. It is a mechanism that allows computation to continue on success, but to escape on error. It is commonly used for fault-tolerant code in functional languages.

Every time I hear someone complain about exceptions, my immediate reaction is: “Just catch them early and use a Result.” You probably think the same way, right? The problem is that everyone writes their own Result type, and they’re all incompatible.

Many people mention Vavr as a “functional” Java library. Is it becoming a de facto standard? Maybe. But these functional libraries tend to grow and get hard to follow, especially for programmers who aren’t used to functional jargon. Maybe the real sweet spot is to write an OO-friendly Result, something naive and standalone, and then, if needed, explore a more functionally advanced version later. The version below is intentionally missing things like orElse to keep it closer to Optional.

Simple Result

Let’s start simple. Result is an algebraic data type permitting Success or Failure, implemented using sealed interfaces and permits. It has a map function that applies a function if the Result is a Success; in case of Failure, it simply forwards the failure.

import java.util.function.Function;

// A first very simple Result type 

public sealed interface Result<T, E> permits Result.Success, Result.Failure {
    record Success<T, E>(T value) implements Result<T, E> { }
    record Failure<T, E>(E error) implements Result<T, E> { }
    
    // Applies a function if Result is Success, while Failure is returned as is.
    default <U> Result<U, E> map(Function<? super T, ? extends U> mapper) {
        return switch (this) {
            case Success<T, E> success -> success(mapper.apply(success.value()));
            case Failure<T, E> failure -> failure(failure.error());
        };
    }
}

Start by lifting a number (2) into the context of where success is an Integer and failure is a String (an error message). The map function, which increases the value by 4, will be applied only if the value is a Success. Thereafter the value can be inspected whether it is success or failure.

Result<Integer, String> value = new Success<>(2);
// increment as it is Success value
value = succeeding.map(x -> x + 4);
// Will print 6, in Success case 
print( switch (value){
          case Success<Integer, ?> success -> success.value();
          case Failure<?, String> failure -> failure.error();
       });

If Success, the map functions takes the value out of the context and returns an altered value lifted back into the context. The map function will not be applied if the value is a Failure

Result<Integer, String> failing = new Failure<>("Not good at all");
// The map function only applies to Success 
// Nothing is increased, and the Failure is returned
var result = failing.map( x -> x+4);
// The result is still Failure
print( switch (result){
          case Success<Integer, ?> success -> "Should not occur. Not Success:" + success;
          // The error string "Not good at all" will be printed in Failure case
          case Failure<?, String> failure -> failure.error()
       });

Compared to throwing exceptions, the failure is simply propagated the original execution path, rather than being thrown through an extra execution paths. There is additional path to reason about.

Pattern matching

Since Java got pattern matching the values can be digged out, with simpler code as a result

Result<Integer, String> failing = new Failure<>("Not good at all");
var result = failing.map( x -> x+4);
print( switch (result){
            // The result can be matched and destructured
            case Success(var value) -> "Should not occur. Not Success:" + value;
            // And the types can be cheked at runtime 
            case Failure(ArithmeticException ae) -> "Math issues:" + ae.getMessage();
            // If any other exception occured
            case Failure(Exception e) -> e.getMessage();
       });

Choosing path with flatMap

But what if the operation fails, such as dividing by zero? This is where flatMap comes into play. Here you get to decide whether it’s a success or a failure yourself, rather than automatic lifting into the context.

Grow the Result type som more, and add static success and failure functions to swiftly lift values into context, and flatMap that on Success applies a function that let you choose the outcome. The flatMap allow you to propagate errors or chain computations that can fail.

import java.util.function.Function;

public sealed interface Result<T, E> permits Result.Success, Result.Failure {
    record Success<T, E>(T value) implements Result<T, E> { }
    record Failure<T, E>(E error) implements Result<T, E> { }
   
    default <U> Result<U, E> map(Function<? super T, ? extends U> mapper) {
        return switch (this) {
            case Success(var value) -> success(mapper.apply(value));
            case Failure(var error) -> failure(error);
        };
    }
    
    //Functions for swiftly creating success and failure

    static <T, E> Result<T, E> success(T value) {
        return new Success<>(value);
    }

    static <T, E> Result<T, E> failure(E error) {
        return new Failure<>(error);
    }
    
    // Applies a function if Result is Success, but let the function decide whether 
    // it's success or failure. Failure is returned as is.
    default <U> Result<U, E> flatMap(Function<? super T, Result<U, E>> mapper) {
        return switch (this) {
            case Success(var value) -> mapper.apply(value);
            case Failure(var error) -> failure(error);
        };
    }
}

Example with flatMap

Look at an example where flatmap is used:

Result<Integer, String> succeeding = success(2);

// The increment won’t fail, so lift as Success.
var next = succeeding.flatMap(x -> success(x + 4));

//Division by zero will return a failure rather than success. See below
next = next.flatMap(x -> safeDivision(x,0));

// Nothing will be incremented since safeDivision(x,0) returned Failure.
// The flatMap will simply forward Failure as Failure.
next = next.flatMap(x -> success( x + 3));

// safeDivision(x,0) lifted into failure context
print( switch (next) {
          case Success(var value) -> "Should not occur:" + value;
          // Will print "/ by zero" in Failure case
          case Failure(var error) -> error;
       });

// safeDivision, used above, is lifting into Result context, with failure on Exception 
Result<Integer,String> safeDivision(int x, int y ){
    try{
        return success(x / y);
    }
    catch (ArithmeticException e){
        return failure(e.getMessage());
    }
}

The safeDivision function takes care of the exception early by converting it to a failure, which is returned and follows the normal path rather than being thrown. Functions like map and flatMap replace the alternative flow of throwing exceptions and keep the happy path clearer and more predictable.

Optional comparison

The Result type is like Optional, already existing in the JDK, but represent success or error rather than of value or absence. Optional is not for distinguishing between kinds of failure. With Result, you carry error context and can act on it.

A similar example with Optional

// Make a map for number translation, with the keys [1,2,3]
// Purposely with no higher keys 
var map = Map.of( 1, 11,  2, 22,  3, 33);

// Lift into a Optional context
var o = Optional.of(2);

// Increase lifted value 
o= o.map(x -> x + 4);

// Lookup the value for translation, that fails with null.
// Context becomes absent value
o= o.flatMap(x -> Optional.ofNullable(map.get(x)));

// Option will neither map or flatmap on absence
o= o.flatMap( x -> Optional.of(x + 3));

// Will print "No value", due to absence
print(o.map(x -> x).orElse("No value"));

Taking care of exceptions

It is obviously possible to handle exceptions in a Result, by translating an exception into a Result.

The function trial below lifts the result of applying a function into the context, returning a failure if an exception occurs. It is closely related to the success and failure functions.


    /** Lifts the result of the supplier into Failure on exception, or success */ 
    static <T, E extends Exception> Result<T, E> trial(ThrowingSupplier<T, E> supplier) {
        try {
            return success(supplier.get());
        } catch (Exception e) {
            // noinspection unchecked
            return failure((E) e); // Cast to E since we expect it to extend Exception.
        }
    }

Result.trial starts as a Success Result, just like success, but produces a failure in case of division by zero. The exception type becomes RuntimeException, as that’s all the lambda can guarantee; it does not declare any exceptions. Exception handling is hidden and the flow remains on the happy path, rather than causing quick stack unwinding.

// The x -> x+3 lambda is never called as trial returns failure   
var result = Result.trial(() -> 3/0).map( x -> x+3);
print( switch (result) {
          case Success(var value) -> value;
          case Failure(var exception) -> exception.getMessage();
       });

mapTrial

The following function, mapTrial, acts like map, but creates a Failure if the mapper function throws an exception.

    /** Like map, but lifts into failure on raised exception */
    default <U> Result<U, E> mapTrial(Function<? super T, ? extends U> mapper) {
        return switch (this) {
            case Success(var value) -> {
                try {
                    yield success(mapper.apply(value));
                } catch (Exception e) {
                    // noinspection unchecked
                    yield failure((E) e);
                }
            }
            case Failure(var error) -> failure( error);
        };
    }    

The example starts with Success, but uses mapTrial to create a Failure if the supplied function throws. Here, it throws due to division by zero. The exception is caught early and transformed into a Failure, making it easy to reason about with a single program path.

Result<Integer, Exception> result = Result.success(2);
result = result.mapTrial(x -> x/0);
print( switch (res) {
          case Success(var value) ->  value;
          case Failure(var exception) ->  exception.getMessage();
       });

Failure transformation

Failures need special care, and mapFailure acts like map, but operates on failures rather than successes.

    default <F> Result<T, F> mapFailure(Function<? super E, ? extends F> mapper) {
        return switch (this) {
            case Success(var value) -> success(value);
            case Failure(var error) -> failure(mapper.apply(error));
        };
    }

Rather than exposing the exception directly, you can transform it into an error message, turning the result into a Result<Integer, String> rather than a Result<Integer, RuntimeException>:

var result = Result.trial(() -> 3/0).map( x -> x+3);
var resultWithString = result.mapFailure(ex -> "Exception in the example: "+ ex.getMessage());
print( switch (resultWithString) {
          case Success(var value) -> "Yes " + value;
          case Failure(var error)  -> error;
       });

An overload of mapTrial, with a exceptionMapper solves the failure transformation immediately.

    default <U> Result<U, E> mapTrial(Function<? super T, ? extends U> mapper,
                                      Function<? super Exception, E> exceptionMapper) {
        return switch (this) {
            case Success<T, E> success -> {
                try {
                    yield success(mapper.apply(success.value()));
                }catch (Exception e) {
                    yield failure(exceptionMapper.apply(e));
                }
            }
            case Failure<T, E> failure -> failure( failure.error());
        };
    }

And the exception is now just another value in the normal flow.

var result = Result.trial(() -> 3/0, 
                          Exception::getMessage)
                .map( x -> x+3);

print( switch(resultWithString) {
          case Success(var value) -> "Yes " + value;
          case Failure(var error)  -> error;
       });

Summary

It is not that difficult to get rid of complex exceptional paths, and make code easier to live with. As Result does not yet exist in the JDK, I do encourage you to roll your own. Handling exceptions is core control structure, should be in your control, and not in some third party library. Feel free to copy and adjust.

There are situations when throwing exceptions are better. There are those situations where it’s useless to continue, like on programming or invariant errors, hard system errors. And in general, when result handling is too deep, and exceptions become easier to reson with. Use the best tool for the situation. Result is better for expected errors in domain or IO code, where it is meaningful react and recover.

Result.java


All #art #clojure #csharp #data-structures #database #datomic #emacs #fortran #fsharp #functional #haskell #history #immutability #java #jit #jmm #lambdas #lisp #pioneers #poetry #programming #programming-philosophy #randomness #rant #reducers #repl #smalltalk #sql #threads #women