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 with Stream.gather(Gatherer.ofSequential(...)) (finalized in Java 24)
to achieve true short-circuit — a design decision documented in ADR-010 — Gatherer for sequence/traverse with true short-circuit.
// 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 decision to use NonEmptyList<V> instead of List<V> is documented in ADR-017 — Result.groupingBy returns Map<K, NonEmptyList<V>> instead of Map<K, List<V>>.
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));Collector facade: Results
Results is a single discoverable entry point for all Stream<Result<V,E>> collectors —
analogous to java.util.stream.Collectors. Import dmx.fun.Results instead of
remembering which collector lives on which type.
The decision to provide companion facade classes as pure-delegation entry points is documented in ADR-014 — Facade collectors (Results, Options, Tries) as a single entry point.
| Method | Returns | Semantics |
|---|---|---|
Results.toList() | Result<List<V>, E> | Fail-fast — first Err short-circuits |
Results.partitioning() | Results.Partition<V, E> | Collect all — splits oks() and errors() |
Results.groupingBy(classifier) | Map<K, NonEmptyList<V>> | Group by key; values are always non-empty |
Results.groupingBy(classifier, finisher) | Map<K, R> | Group then transform each group |
Results.Partition is a thin wrapper that delegates to Result.Partition.
Call toResultPartition() to obtain the underlying Result.Partition<V, E> when needed.
// ── Results.toList() — returns first Err (stream still fully traversed) ──────Result<List<Integer>, String> validated = Stream.<Result<Integer, String>>of(Result.ok(1), Result.ok(2), Result.ok(3)) .collect(Results.toList());// → Ok([1, 2, 3])
Result<List<Integer>, String> firstFails = Stream.<Result<Integer, String>>of(Result.ok(1), Result.err("bad"), Result.ok(3)) .collect(Results.toList());// → Err("bad")
// Real-world: validate every line in a CSV upload — returns first parse error// Note: all lines are parsed; toList() does not short-circuit upstream evaluation.// Do not rely on early termination for expensive or side-effecting parse logic.Result<List<OrderRow>, String> rows = csvLines.stream() .map(OrderRow::parse) // Stream<Result<OrderRow, String>> .collect(Results.toList()); // Ok(all rows) or first Err encountered
// ── Results.partitioning() — process all, split into oks and errors ───────────Results.Partition<OrderRow, String> p = csvLines.stream() .map(OrderRow::parse) .collect(Results.partitioning());
List<OrderRow> good = p.oks(); // rows that parsed successfullyList<String> bad = p.errors(); // parse error messages
// Real-world: import as many records as possible; report failures separatelyimportService.bulkInsert(p.oks());auditLog.recordRejections(p.errors());
// ── Results.groupingBy() — group elements by a derived key ───────────────────Map<Status, NonEmptyList<Order>> byStatus = orders.stream() .collect(Results.groupingBy(Order::status));// Every map value is NonEmptyList — the non-emptiness is in the type
// Downstream variant: count orders per statusMap<Status, Integer> countByStatus = orders.stream() .collect(Results.groupingBy(Order::status, NonEmptyList::size));Interoperability
| Conversion | Method | Notes |
|---|---|---|
Result → Option<V> | result.toOption() | Err → None; discards the error |
Result → Try<V> | result.toTry(errorMapper) | Maps error to Throwable |
Result → Either<E, V> | result.toEither() | Ok → right; Err → left |
Result → Optional<V> | result.toOptional() | Err → Optional.empty(); discards the error |
Result → CompletableFuture<V> | result.toFuture() | Err → failed future with NoSuchElementException |
Result → CompletableFuture<V> | result.toFuture(errorMapper) | Err → failed future with mapped Throwable |
Option → Result | Result.fromOption(option, errorIfNone) | None → Err(errorIfNone) |
Try → Result | tryVal.toResult() | Failure → Err(throwable) |
Optional → Result | Result.fromOptional(optional) | empty → Err(NoSuchElementException) |
Either<E, V> → Result | Result.fromEither(either) | left → Err; right → Ok |
CompletableFuture → Result | Result.fromFuture(future) | Blocks; unwraps CompletionException |
Result<String, String> ok = Result.ok("hello");Result<String, String> err = Result.err("oops");
// Result → Option (discards the error; Err → None)Option<String> opt = ok.toOption();
// Result → Try (maps error to Throwable)Try<String> tried = ok.toTry(e -> new RuntimeException(e));
// Result → Either<E, V> (Ok → right, Err → left)Either<String, String> either = ok.toEither();
// Result → Optional<V> (Err → Optional.empty())Optional<String> javaOpt = ok.toOptional();
// Result → CompletableFuture<V> (Err → failed future with NoSuchElementException)CompletableFuture<String> future = ok.toFuture();
// Result → CompletableFuture<V> with error mapperCompletableFuture<String> future2 = err.toFuture(e -> new IllegalStateException(e));
// ── From other types ──────────────────────────────────────────────────────────
// Option → ResultOption<String> option = Option.some("hello");Result<String, String> fromOpt = option.toResult("not found");
// Try → Result (keeps Throwable as typed error)Try<String> t = Try.of(() -> Files.readString(path));Result<String, Throwable> fromTry = t.toResult();
// Optional → Result (empty → Err(NoSuchElementException))Result<String, NoSuchElementException> fromOptional = Result.fromOptional(Optional.of("hello"));
// Either<E, V> → Result (left → Err, right → Ok)Either<String, Integer> right = Either.right(42);Result<Integer, String> fromEither = Result.fromEither(right);
// CompletableFuture → Result (blocks; unwraps CompletionException)Result<String, Throwable> fromFuture = Result.fromFuture(CompletableFuture.completedFuture("done"));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.