functional-reactive
Made in the European Union

Error handling with Result

Replace try/catch ladders and Optional chains with a Result-based workflow that keeps the reason for every failure.

May 16, 2026

Optional<T> is great at signalling “no value here.” But the moment you ask why there is no value, you find yourself reaching for try/catch, sentinel returns or out-parameters again. This tutorial walks through replacing those patterns with Result<T> โ€” a Success/Failure container that keeps the reason and composes cleanly with the rest of the JDK.

By the end you will know how to:

  • pick Result over Optional based on what the caller needs,
  • chain transformations without exception bookkeeping,
  • compose multi-stage workflows that can be built once and run later,
  • bridge to Optional, Stream and CompletableFuture.

The problem

We have a pipeline that:

  1. reads a configuration value (may be absent),
  2. parses it into an integer (may fail),
  3. looks up a record by that id (may fail),
  4. renders a label (cannot fail).

A typical first attempt looks like this:

String raw = System.getProperty("user.id");
if (raw == null) {
    log.warn("user.id not set");
    return;
}

int id;
try {
    id = Integer.parseInt(raw);
} catch (NumberFormatException ex) {
    log.warn("user.id is not a number: {}", raw);
    return;
}

User user = repository.findById(id);
if (user == null) {
    log.warn("no user for id {}", id);
    return;
}

System.out.println(user.displayName());

Four early returns, three different failure messages, no way to compose this into a larger workflow. Let’s replace it with Result.

Step 1: lift each stage into Result

Every operation that can fail returns a Result<T>. The trick is to keep each stage focused on one thing, and use Result.ofNullable and Result.failure to surface absence with a reason.

import com.svenruppert.functional.model.Result;

Result<String> raw = Result.ofNullable(
    System.getProperty("user.id"),
    "system property user.id is not set"
);

For parsing, we will lean on a CheckedFunction โ€” a functional interface that turns a throwing method reference into a Function<String, Result<Integer>>:

import com.svenruppert.functional.functions.CheckedFunction;

CheckedFunction<String, Integer> parseInt = Integer::parseInt;

Calling parseInt.apply("42") yields Result.success(42); calling it with "oops" yields a Result.failure whose message is the exception’s message.

For the repository lookup we return a Result directly:

Result<User> findById(int id) {
    User u = repository.findById(id);
    return (u != null) ? Result.success(u)
                       : Result.failure("no user for id " + id);
}

Step 2: chain with map and flatMap

map transforms the value if present and propagates failure as-is. flatMap is the same idea, but for transformations that themselves return a Result:

Result<String> label = raw
    .flatMap(parseInt)              // Result<Integer>
    .flatMap(this::findById)        // Result<User>
    .map(User::displayName);        // Result<String>

Each flatMap is a fork in the road: if the previous stage failed, the rest of the chain short-circuits and the failure flows through with its original reason.

Step 3: terminate

At the end of the chain, decide what to do with the outcome. ifPresentOrElse is the most common terminator:

label.ifPresentOrElse(
    System.out::println,
    err -> log.warn("could not render label: {}", err)
);

That’s the entire pipeline. Four early returns collapse to four lines of declarative code, every failure still carries its reason, and adding a new stage means adding one line.

Combining with external values

Real workflows often need to merge the current value with something else โ€” a timestamp, a config value, the result of another service call. That’s what thenCombine is for:

Result<String> tagged = label
    .thenCombine(
        LocalDateTime.now(),
        (name, ts) -> Result.success(name + " @ " + ts)
    );

When the second value is expensive or you want to defer it, pass a Supplier:

import java.util.function.Supplier;

label.thenCombine(
    (Supplier<Long>) System::nanoTime,
    (name, clock) -> Result.success(name + " @ " + clock.get())
);

For async combination, use thenCombineAsync โ€” the combine runs on the common ForkJoinPool and you get back a CompletableFuture<Result<R>> you can join, accept or chain further.

Building workflows once, running them later

Result plays well with Function because every step is just a Function<T, Result<R>>. That means you can assemble a whole pipeline as a value and apply it to inputs later:

public static Function<Result<String>, Result<Step003>> workflow =
    input -> input
        .or(() -> Result.success("nooop"))     // default
        .thenCombine(serviceA(),
            (value, supplier) -> Result.success(supplier.get()))
        .thenCombine(serviceB(),
            (step1, fn)       -> Result.success(fn.apply(step1)))
        .thenCombine(serviceC(),
            (step2, fn)       -> Result.success(fn.apply(step2)));

workflow is now a plain Function you can pass around, decorate (e.g. add logging via andThen), and apply whenever the input arrives:

Function<Result<String>, Result<Step003>> withLogging = workflow.andThen(r -> {
    r.ifPresentOrElse(
        v   -> log.info("done: {}", v),
        err -> log.warn("failed: {}", err)
    );
    return r;
});

withLogging.apply(service.doWork("Hello"));

The functions are stateless and reusable. Different callers can decorate the same base workflow differently โ€” one for tests, one for production โ€” without touching the original.

Bridging back to Optional and Stream

When you need to interact with an API that speaks Optional or Stream, the bridges are one method call away:

Optional<User> asOptional = userResult.toOptional();

Stream.of("1", "2", "Hi", "3")
      .map((CheckedFunction<String, Integer>) Integer::valueOf)
      .flatMap(Result::stream)            // failed parses drop out silently
      .reduce(Integer::sum)
      .ifPresent(System.out::println);    // 6

The Stream::flatMap pattern is particularly handy: Result::stream returns zero or one element, so failed parses simply disappear from the stream.

When not to use Result

Result.failure carries a message, not a Throwable. If your caller genuinely needs the original exception (e.g. for a stack trace in an error report) you have two options:

  • log the throwable at the point of conversion (typically inside a CheckedFunction) and let Result carry forward the human-readable reason, or
  • introduce a small Result<Throwable, T>-style type for that specific boundary.

For 90% of business code the first option is the right call โ€” stack traces belong in logs, not in domain types.

Recap

  • Result<T> is Optional<T> plus a reason for the failure.
  • map / flatMap propagate failure through a chain without explicit try/catch.
  • thenCombine and thenCombineAsync merge an external value into the pipeline.
  • Workflows can be built as Function values and applied later, decorated, or recomposed.
  • toOptional, stream and CompletableFuture-returning combinators keep Result interoperable.

Next