The TryMonad:

Typed Error Handling

What You'll Learn

  • How to handle exceptions functionally with Success and Failure cases
  • Converting exception-throwing code into composable, safe operations
  • Using recover and recoverWith for graceful error recovery
  • Building robust parsing and processing pipelines
  • When to choose Try vs Either for error handling

See Example Code:

Purpose

The Try<T> type in the Higher-Kinded-J library represents a computation that might result in a value of type T (a Success) or fail with a Throwable (a Failure). It serves as a functional alternative to traditional try-catch blocks for handling exceptions, particularly checked exceptions, within a computation chain. We can think of it as an Either where the Left is an Exception, but also using try-catch blocks behind the scene, so that we don’t have to.

Try Type

try_type.svg

Monadic Structure

try_monad.svg

Key benefits include:

  • Explicit Error Handling: Makes it clear from the return type (Try<T>) that a computation might fail.
  • Composability: Allows chaining operations using methods like map and flatMap, where failures are automatically propagated without interrupting the flow with exceptions.
  • Integration with HKT: Provides HKT simulation (TryKind) and type class instances (TryMonad) to work seamlessly with generic functional abstractions operating over Kind<F, A>.
  • Error Recovery: Offers methods like recover and recoverWith to handle failures gracefully within the computation chain.

It implements MonadError<TryKind<?>, Throwable>, signifying its monadic nature and its ability to handle errors of type Throwable.

Now that we understand the structure and benefits of Try, let's explore how to create and work with Try instances in practice.

How to Use Try<T>

Creating Instance

You can create Try instances in several ways:

  1. Try.of(Supplier): Executes a Supplier and wraps the result in Success or catches any thrown Throwable (including Error and checked exceptions) and wraps it in Failure.

    import org.higherkindedj.hkt.trymonad.Try;
    import java.io.FileInputStream;
    
    // Success case
    Try<String> successResult = Try.of(() -> "This will succeed"); // Success("This will succeed")
    
    // Failure case (checked exception)
    Try<FileInputStream> failureResult = Try.of(() -> new FileInputStream("nonexistent.txt")); // Failure(FileNotFoundException)
    
    // Failure case (runtime exception)
    Try<Integer> divisionResult = Try.of(() -> 10 / 0); // Failure(ArithmeticException)
    
  2. Try.success(value): Directly creates a Success instance holding the given value (which can be null).

    Try<String> directSuccess = Try.success("Known value");
    Try<String> successNull = Try.success(null);
    
  3. Try.failure(throwable): Directly creates a Failure instance holding the given non-null Throwable.

    Try<String> directFailure = Try.failure(new RuntimeException("Something went wrong"));
    

Checking the State

  • isSuccess(): Returns true if it's a Success.
  • isFailure(): Returns true if it's a Failure.

Getting the Value (Use with Caution)

  • get(): Returns the value if Success, otherwise throws the contained Throwable. Avoid using this directly; prefer fold, map, flatMap, or recovery methods.

Transforming Values (map)

Applies a function to the value inside a Success. If the function throws an exception, the result becomes a Failure. If the original Try was a Failure, map does nothing and returns the original Failure.

Try<Integer> initialSuccess = Try.success(5);
Try<String> mappedSuccess = initialSuccess.map(value -> "Value: " + value); // Success("Value: 5")

Try<Integer> initialFailure = Try.failure(new RuntimeException("Fail"));
Try<String> mappedFailure = initialFailure.map(value -> "Value: " + value); // Failure(RuntimeException)

Try<Integer> mapThrows = initialSuccess.map(value -> { throw new NullPointerException(); }); // Failure(NullPointerException)

Chaining Operations (flatMap)

Applies a function that returns another Try to the value inside a Success. This is used to sequence operations where each step might fail. Failures are propagated.

Function<Integer, Try<Double>> safeDivide =
value -> (value == 0) ? Try.failure(new ArithmeticException("Div by zero")) : Try.success(10.0 / value);

Try<Integer> inputSuccess = Try.success(2);
Try<Double> result1 = inputSuccess.flatMap(safeDivide); // Success(5.0)

Try<Integer> inputZero = Try.success(0);
Try<Double> result2 = inputZero.flatMap(safeDivide); // Failure(ArithmeticException)

Try<Integer> inputFailure = Try.failure(new RuntimeException("Initial fail"));
Try<Double> result3 = inputFailure.flatMap(safeDivide); // Failure(RuntimeException) - initial failure propagates

Handling Failures (fold, recover, recoverWith)

fold(successFunc, failureFunc)

Safely handles both cases by applying one of two functions.

String message = result2.fold(
    successValue -> "Succeeded with " + successValue,
    failureThrowable -> "Failed with " + failureThrowable.getMessage()
); // "Failed with Div by zero"

recover(recoveryFunc)

If Failure, applies a function Throwable -> T to produce a new Success value. If the recovery function throws, the result is a Failure containing that new exception.

Function<Throwable, Double> recoverHandler = throwable -> -1.0;
Try<Double> recovered1 = result2.recover(recoverHandler); // Success(-1.0)
Try<Double> recovered2 = result1.recover(recoverHandler); // Stays Success(5.0)

recoverWith(recoveryFunc)

Similar to recover, but the recovery function Throwable -> Try<T> must return a Try. This allows recovery to potentially result in another Failure.

Function<Throwable, Try<Double>> recoverWithHandler = throwable ->
    (throwable instanceof ArithmeticException) ? Try.success(Double.POSITIVE_INFINITY) : Try.failure(throwable);

Try<Double> recoveredWith1 = result2.recoverWith(recoverWithHandler); // Success(Infinity)
Try<Double> recoveredWith2 = result3.recoverWith(recoverWithHandler); // Failure(RuntimeException) - re-raised

Example: Using TryMonad

To use Try with generic code expecting Kind<F, A>:

  1. Get Instance:TryMonad tryMonad = TryMonad.INSTANCE;
  2. Wrap(Widen): Use TRY.widen(myTry) or factories like TRY.tryOf(() -> ...).
  3. Operate: Use tryMonad.map(...), tryMonad.flatMap(...), tryMonad.handleErrorWith(...) etc.
  4. Unwrap(Narrow): Use TRY.narrow(tryKind) to get the Try<T> back.

TryMonad tryMonad = TryMonad.INSTANCE;

Kind<TryKind.Witness, Integer> tryKind1 = TRY.tryOf(() -> 10 / 2); // Success(5) Kind
Kind<TryKind.Witness, Integer> tryKind2 = TRY.tryOf(() -> 10 / 0); // Failure(...) Kind

// Map using Monad instance
Kind<TryKind.Witness, String> mappedKind = tryMonad.map(Object::toString, tryKind1); // Success("5") Kind

// FlatMap using Monad instance
Function<Integer, Kind<TryKind.Witness, Double>> safeDivideKind =
        i -> TRY.tryOf(() -> 10.0 / i);
Kind<TryKind.Witness, Double> flatMappedKind = tryMonad.flatMap(safeDivideKind, tryKind1); // Success(2.0) Kind

// Handle error using MonadError instance
Kind<TryKind.Witness, Integer> handledKind = tryMonad.handleErrorWith(
        tryKind2, // The Failure Kind
        error -> TRY.success(-1) // Recover to Success(-1) Kind
);

// Unwrap
Try<String> mappedTry = TRY.narrow(mappedKind); // Success("5")
Try<Double> flatMappedTry = TRY.narrow(flatMappedKind); // Success(2.0)
Try<Integer> handledTry = TRY.narrow(handledKind); // Success(-1)

System.out.println(mappedTry);
System.out.println(flatMappedTry);
System.out.println(handledTry);