Result<V, E>
Runnable example:
ResultSample.java
What is Result<V, E>?
Result<V, E> models a computation that either succeeds with a value of type V
or fails with a typed error of type E.
It is a sealed interface with two implementations:
Ok<V, E>— holds a non-null success value.Err<V, E>— holds a non-null typed error.
The key distinction from Try<V> is that the error type is
explicit and domain-specific — not a raw Throwable.
This forces callers to acknowledge the error domain and handle it by type,
rather than catching a generic exception.
Use Result when you own the error type and callers need to
branch on it. Use Try when wrapping legacy or third-party code that throws.
Creating instances
Result<User, String> ok = Result.ok(user);Result<User, String> err = Result.err("user not found");Both Ok and Err reject null — pass a non-null value.
If the success case has no meaningful value, use Result<Void, E>
with a sentinel like Result.ok(Boolean.TRUE) or a dedicated unit type instead.
Checking state
Predicates are available, but prefer structural operations in most cases:
result.isSuccess() // true if Okresult.isError() // true if ErrUse exhaustive pattern matching in a switch expression to handle both tracks:
switch (result) { case Result.Ok<User, String>(var user) -> System.out.println("ok: " + user.name()); case Result.Err<User, String>(var err) -> System.out.println("err: " + err);}Extracting values
| Method | When Err | Notes |
|---|---|---|
get() | Throws NoSuchElementException | Only safe after an isSuccess() check; prefer alternatives. |
getOrElse(fallback) | Returns fallback | Fallback is always evaluated — use getOrElseGet for expensive defaults. |
getOrElseGet(supplier) | Calls supplier | Lazily computed fallback. |
getOrNull() | Returns null | Bridge to null-expecting APIs. Avoid propagating the null further. |
getOrThrow(exMapper) | Throws exception from mapper | Maps the error to an exception — good for domain boundary re-throws. |
fold(onSuccess, onError) | Calls onError | The most expressive extractor: forces both tracks to be handled. |
// fold — the preferred extractor: both branches are forcedString response = result.fold( user -> "Welcome, " + user.name(), error -> "Error: " + error);
// Eager fallbackUser user = result.getOrElse(User.anonymous());
// Lazy fallback (supplier called only on Err)User user = result.getOrElseGet(() -> userRepo.defaultUser());
// Throw a domain exception when an error is a programming errorUser user = result.getOrThrow(e -> new IllegalStateException("Unexpected: " + e));Transforming values
map(mapper)
Transforms the success value. The error track passes through unchanged.
Result<String, String> name = result.map(User::name);// Ok("Alice") or the original Err unchangedmapError(mapper)
Transforms the error value. The success track passes through unchanged. Useful for translating between error domains at architectural boundaries.
// Translate the error type while preserving the value trackResult<User, OrderError> translated = result.mapError(msg -> new OrderError(msg));flatMap(mapper)
Like map, but the mapper itself returns a Result.
Use this to chain fallible operations without nesting.
Result<Profile, String> profile = findUser(id) // Result<User, String> .flatMap(user -> loadProfile(user)); // returns Result<Profile, String>filter(predicate, error)
Converts an Ok to Err when the predicate fails.
Accepts a static error value or a function that derives the error from the value.
// Filter with a static error valueResult<User, String> active = result.filter(User::isActive, "account is inactive");
// Filter with a dynamic error derived from the valueResult<User, String> active = result.filter(User::isActive, u -> "account " + u.id() + " is inactive");peek / peekError
Side-effecting operations (logging, metrics) that do not alter the Result.
Return this for chaining.
result .peek(user -> log.info("Processing user {}", user.id())) // on Ok .peekError(err -> log.warn("Request failed: {}", err)) // on Err .map(User::name);match(onSuccess, onError)
Terminal side-effecting variant of fold. Both consumers return void.
Use for logging, metrics, or dispatching to side-effecting systems.
Recovery and fallbacks
Recovery operations convert an Err back to an Ok by inspecting the error value.
// recover: Err -> Ok using the error valueResult<User, String> result = findUser(id).recover(err -> User.guest());
// recoverWith: Err -> Result (may itself fail)Result<User, String> result = findUser(id).recoverWith(err -> findUserByEmail(err));For a simple static fallback without inspecting the error, use orElse.
sequence and traverse
Both operations apply fail-fast semantics: they stop at the first
Err and return it immediately, without consuming the rest of the stream.
This is implemented internally with a Stream Gatherer.
// sequence: List<Result<V,E>> -> Result<List<V>, E>// Stops at the first Err (fail-fast)List<Result<User, String>> lookups = ids.stream() .map(userRepo::findById) .toList();
Result<List<User>, String> allOrFirst = Result.sequence(lookups);
// traverse: applies a fallible mapper to each element and collectsResult<List<String>, String> names = Result.traverse(ids, id -> userRepo.findById(id).map(User::name));If you need all results regardless of failure, use Result.partitioningBy()
as a Collector — it separates Ok values and errors into
two lists, consuming the entire stream.
Grouping with Result.groupingBy
Result.groupingBy(classifier) groups stream elements by a derived key into a
Map<K, NonEmptyList<V>>. Unlike Collectors.groupingBy, the value type is
NonEmptyList<V> rather than List<V>, making the non-emptiness of every group
explicit in the type. The returned Map<K, NonEmptyList<V>> is insertion-order
(encounter order of the stream) and unmodifiable.
The downstream variant groupingBy(classifier, downstream) applies a
Function<NonEmptyList<V>, R> to each group after grouping, producing a
Map<K, R>. Like the base overload, the result is insertion-order and
unmodifiable.
// Group by a key — each group is a guaranteed-non-empty NonEmptyListMap<Integer, NonEmptyList<String>> byLength = Stream.of("a", "bb", "cc", "ddd") .collect(Result.groupingBy(String::length));// {1 -> ["a"], 2 -> ["bb", "cc"], 3 -> ["ddd"]}
// Downstream variant — transform each group after groupingMap<Integer, Long> countByLength = Stream.of("a", "bb", "cc", "ddd") .collect(Result.groupingBy(String::length, nel -> (long) nel.size()));// {1 -> 1, 2 -> 2, 3 -> 1}
// Domain example — group users by roleMap<Role, NonEmptyList<User>> byRole = users.stream() .collect(Result.groupingBy(User::role));
// Count per role using the downstream variantMap<Role, Integer> countByRole = users.stream() .collect(Result.groupingBy(User::role, NonEmptyList::size));Interoperability
| Conversion | Method |
|---|---|
Result → Option | result.toOption() |
Result → Try | result.toTry(errorMapper) |
Result → Either | result.toEither() |
Result → CompletableFuture | result.toFuture() |
Option → Result | option.toResult(errorSupplier) |
Try → Result | tryVal.toResult() |
Optional → Result | Result.fromOptional(optional) |
CompletableFuture → Result | Result.fromFuture(future) |
// Result -> Option (discards the error)Option<User> user = result.toOption();
// Result -> Try (maps error with a function)Try<User> tried = result.toTry(err -> new RuntimeException(err));
// Result -> Either (Ok becomes right, Err becomes left)Either<String, User> either = result.toEither();
// Option -> Result (supplies error when None)Result<User, String> result = Option.fromOptional(optional) .toResult(() -> "not found");See the Combining Types page for the full conversion matrix and composition patterns.
Result vs Try vs exceptions
| Exceptions | Try<V> | Result<V, E> | |
|---|---|---|---|
| Error type | Throwable — implicit | Throwable — explicit value | Any type — explicit and domain-specific |
| Forces handling | Only checked exceptions | Yes — via API | Yes — via API and switch patterns |
| Branch on error subtype | Via catch clauses | Via instanceof | Via sealed subtype and switch patterns |
| Interop with throwing code | Native | Excellent — Try.of(() -> ...) | Indirect — wrap with Try first |
| Best for | Truly exceptional situations | Wrapping legacy/third-party code | Domain logic with typed failure modes |
Common pitfalls
Nested Results
Result<Result<V, E>, E> almost always indicates a mistake.
Use flatMap instead of map when the mapper returns a Result.
// Bad: creates Result<Result<Profile, String>, String>Result<Result<Profile, String>, String> nested = findUser(id).map(user -> loadProfile(user));
// Good: flat resultResult<Profile, String> profile = findUser(id).flatMap(user -> loadProfile(user));Ignoring the error track
Calling getOrElse silently discards the error.
Use fold or match to make both branches explicit,
especially in production code paths.
// Bad: silently discards the error trackString name = result.getOrElse(User.anonymous()).name();
// Good: make both branches explicitString name = result.fold( user -> user.name(), error -> { log.warn("Using anonymous due to: {}", error); return "anonymous"; });Using Result for truly exceptional situations
If the error represents a bug or unrecoverable system failure (out of memory, corrupted state),
an exception is still the right tool. Result is for expected,
recoverable failure modes that callers are designed to handle.
Result as a method parameter
Like Option, Result is designed for return types.
Prefer overloaded methods or a nullable/typed parameter over a
Result-typed parameter.
Real-world example
A typed order placement pipeline. Each step is a fallible operation returning a domain-specific error — the compiler enforces that every error case is handled at the call site.
sealed interface OrderError { record InvalidItems(List<String> reasons) implements OrderError {} record PaymentFailed(String code) implements OrderError {} record ShippingUnavailable(String region) implements OrderError {}}
public Result<OrderConfirmation, OrderError> placeOrder(OrderRequest req) { return validateItems(req.items()) // Result<List<Item>, OrderError> .flatMap(items -> charge(req.payment(), items)) // Result<Receipt, OrderError> .flatMap(receipt -> ship(req.address(), receipt)) // Result<Shipment, OrderError> .map(shipment -> new OrderConfirmation(shipment.trackingId()));}Every step propagates its Err automatically via flatMap.
The caller receives a single Result<OrderConfirmation, OrderError>
and branches on the sealed OrderError subtypes.