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 Ok
result.isError() // true if Err

Use 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

MethodWhen ErrNotes
get()Throws NoSuchElementExceptionOnly safe after an isSuccess() check; prefer alternatives.
getOrElse(fallback)Returns fallbackFallback is always evaluated — use getOrElseGet for expensive defaults.
getOrElseGet(supplier)Calls supplierLazily computed fallback.
getOrNull()Returns nullBridge to null-expecting APIs. Avoid propagating the null further.
getOrThrow(exMapper)Throws exception from mapperMaps the error to an exception — good for domain boundary re-throws.
fold(onSuccess, onError)Calls onErrorThe most expressive extractor: forces both tracks to be handled.
// fold — the preferred extractor: both branches are forced
String response = result.fold(
user -> "Welcome, " + user.name(),
error -> "Error: " + error
);
// Eager fallback
User 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 error
User 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 unchanged

mapError(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 track
Result<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 value
Result<User, String> active =
result.filter(User::isActive, "account is inactive");
// Filter with a dynamic error derived from the value
Result<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 value
Result<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 collects
Result<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 NonEmptyList
Map<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 grouping
Map<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 role
Map<Role, NonEmptyList<User>> byRole =
users.stream()
.collect(Result.groupingBy(User::role));
// Count per role using the downstream variant
Map<Role, Integer> countByRole =
users.stream()
.collect(Result.groupingBy(User::role, NonEmptyList::size));

Interoperability

ConversionMethod
ResultOptionresult.toOption()
ResultTryresult.toTry(errorMapper)
ResultEitherresult.toEither()
ResultCompletableFutureresult.toFuture()
OptionResultoption.toResult(errorSupplier)
TryResulttryVal.toResult()
OptionalResultResult.fromOptional(optional)
CompletableFutureResultResult.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

ExceptionsTry<V>Result<V, E>
Error typeThrowable — implicitThrowable — explicit valueAny type — explicit and domain-specific
Forces handlingOnly checked exceptionsYes — via APIYes — via API and switch patterns
Branch on error subtypeVia catch clausesVia instanceofVia sealed subtype and switch patterns
Interop with throwing codeNativeExcellent — Try.of(() -> ...)Indirect — wrap with Try first
Best forTruly exceptional situationsWrapping legacy/third-party codeDomain 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 result
Result<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 track
String name = result.getOrElse(User.anonymous()).name();
// Good: make both branches explicit
String 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.