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
ResultoverOptionalbased 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,StreamandCompletableFuture.
The problem
We have a pipeline that:
- reads a configuration value (may be absent),
- parses it into an integer (may fail),
- looks up a record by that id (may fail),
- 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 letResultcarry 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>isOptional<T>plus a reason for the failure.map/flatMappropagate failure through a chain without explicittry/catch.thenCombineandthenCombineAsyncmerge an external value into the pipeline.- Workflows can be built as
Functionvalues and applied later, decorated, or recomposed. toOptional,streamandCompletableFuture-returning combinators keepResultinteroperable.
Next
Result<T>API reference โ every method, every overload.- Checked Functions โ the canonical way to lift throwing code into
Result. - Async pipelines with CompletableFutureQueue โ when you need the pipeline to be non-blocking end-to-end.