Try<V>

Runnable example: TrySample.java

What is Try<V>?

Try<V> models a computation that either succeeds with a value of type V or fails with a Throwable. It is a sealed interface with two implementations:

  • Success<V> — holds the result value (which may be null for Try<Void>).
  • Failure<V> — holds a non-null Throwable.

Try bridges the gap between exception-throwing code and functional pipelines. Use it to wrap any computation that throws so exceptions become values you can map, chain, and recover from — without try/catch blocks scattered through your logic.

The key distinction from Result<V, E> is that the error type is always Throwable. That makes Try ideal for wrapping legacy or third-party code, but less suitable when you own the error domain and need callers to branch on specific error subtypes.

Creating instances

// Wrap any throwing computation — catches Throwable, not just Exception
Try<Integer> parsed = Try.of(() -> Integer.parseInt(input));
Try<String> content = Try.of(() -> Files.readString(path));
// Wrap a void side-effect (result value is null on success)
Try<Void> written = Try.run(() -> Files.writeString(path, data));
// Construct directly when you already know the outcome
Try<Integer> ok = Try.success(42);
Try<Integer> err = Try.failure(new RuntimeException("oops"));

Try.of catches any Throwable — not just Exception. Try.run is the void-safe variant: it yields Success(null) on success. See the Null handling section for the implications.

Checking state

Try<Integer> t = Try.of(() -> Integer.parseInt(input));
t.isSuccess(); // true if Success
t.isFailure(); // true if Failure
// Exhaustive pattern matching — preferred
String msg = switch (t) {
case Try.Success<Integer> s -> "Parsed: " + s.value();
case Try.Failure<Integer> f -> "Failed: " + f.cause().getMessage();
};

Prefer exhaustive switch patterns over isSuccess()/isFailure() predicates — the compiler enforces that both tracks are handled.

Extracting values

MethodWhen FailureNotes
get()Throws NoSuchElementExceptionOnly safe after an isSuccess() check; prefer alternatives.
getCause()N/A — throws if SuccessRetrieves the exception; unsafe on the success track.
getOrElse(fallback)Returns fallbackFallback is always evaluated eagerly.
getOrElseGet(supplier)Calls supplierLazily computed fallback.
getOrNull()Returns nullAmbiguous for Try<Void> — avoid when possible.
getOrThrow()Rethrows the original exceptionRethrows Exception and Error as-is; wraps bare Throwable.
getOrThrow(exMapper)Throws mapped RuntimeExceptionMaps the cause to a domain exception at the call site.
fold(onSuccess, onFailure)Calls onFailureThe most expressive extractor: forces both tracks to be handled.
Try<Integer> t = Try.of(() -> Integer.parseInt(input));
// Safe: always returns a value
int n = t.getOrElse(0);
int m = t.getOrElseGet(() -> computeDefault());
// Expressive: handles both tracks
String result = t.fold(
value -> "Parsed: " + value,
ex -> "Error: " + ex.getMessage()
);
// Unsafe: throws if Failure
int raw = t.get(); // throws NoSuchElementException
Throwable e = t.getCause(); // throws NoSuchElementException if Success
// Rethrow the original exception (or wrap it)
int val = t.getOrThrow(); // rethrows as-is
int v2 = t.getOrThrow(ex -> new AppException(ex)); // maps to RuntimeException

Transforming values

map(mapper)

Transforms the success value. The failure track passes through unchanged. If the mapper itself throws, the exception is captured as a new Failure.

Try<String> binary = Try.of(() -> Integer.parseInt(input))
.map(Integer::toBinaryString); // Try<Integer> → Try<String>
// If the mapper throws, the exception is captured as a Failure
Try<JsonNode> json = Try.of(() -> Files.readString(path))
.map(Json::parse); // IOException or JsonException → Failure

flatMap(mapper) / flatMapError(mapper)

flatMap chains fallible operations without nesting. flatMapError operates on the failure track — use it to attempt an alternative computation when the primary one fails.

// flatMap chains fallible operations without nesting
Try<Config> config = Try.of(() -> Files.readString(configPath))
.flatMap(text -> Try.of(() -> Json.parse(text)))
.flatMap(json -> Try.of(() -> Config.from(json)));
// Equivalent flatMapError: recover on failure with another Try
Try<String> result = Try.of(() -> fetchRemote(url))
.flatMapError(ex -> Try.of(() -> fetchFallback(url)));

filter(predicate)

Converts a Success to Failure when the predicate returns false. The default failure is IllegalArgumentException; the overloads accept a custom supplier or a value-aware function.

// Default: converts to Failure(IllegalArgumentException) when predicate fails
Try<Integer> positive = Try.of(() -> Integer.parseInt(input))
.filter(n -> n > 0);
// Custom exception supplier
Try<Integer> valid = Try.of(() -> Integer.parseInt(input))
.filter(n -> n > 0, () -> new IllegalArgumentException("Must be positive"));
// Value-aware error (receives the failing value)
Try<Integer> age = Try.of(() -> Integer.parseInt(input))
.filter(n -> n >= 0, n -> new IllegalArgumentException("Negative age: " + n));

onSuccess / onFailure

Side-effecting operations that do not alter the Try. Both return this for chaining. Use match for a terminal void operation that forces both branches.

Try<Integer> t = Try.of(() -> Integer.parseInt(input));
// onSuccess / onFailure — chainable, return this
t.onSuccess(n -> log.info("Parsed: {}", n))
.onFailure(ex -> log.warn("Parse failed: {}", ex.getMessage()));
// match — terminal, void; both branches must be handled
t.match(
value -> metrics.increment("parse.ok"),
ex -> metrics.increment("parse.error")
);

Recovery and fallbacks

Recovery operations convert a Failure back to a Success by inspecting the exception. Typed overloads match only a specific exception subtype.

// recover — map the exception to a fallback value
Try<String> result = Try.of(() -> fetchRemote(url))
.recover(ex -> "default-value");
// recover by exception type — only handles the matched subtype
Try<String> typed = Try.of(() -> fetchRemote(url))
.recover(IOException.class, ex -> "offline-fallback");
// recoverWith — map to a new Try (for chaining another fallible operation)
Try<String> chain = Try.of(() -> fetchPrimary(url))
.recoverWith(ex -> Try.of(() -> fetchSecondary(url)));
// recoverWith by exception type
Try<String> typed2 = Try.of(() -> fetchPrimary(url))
.recoverWith(TimeoutException.class, ex -> Try.of(() -> fetchSecondary(url)));

sequence and traverse

Both operations apply fail-fast semantics: they stop at the first Failure 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.

List<String> inputs = List.of("1", "2", "abc", "4");
// sequence: Stream<Try<V>> → Try<List<V>> — fails fast on first Failure
Try<List<Integer>> allOrNone = Try.sequence(
inputs.stream().map(s -> Try.of(() -> Integer.parseInt(s)))
);
// → Failure(NumberFormatException) — stops at "abc"
// traverse: applies the mapper and collects — same fail-fast behaviour
Try<List<Integer>> result = Try.traverse(inputs, s -> Try.of(() -> Integer.parseInt(s)));
// sequence also accepts an Iterable
List<Try<Integer>> tries = List.of(Try.success(1), Try.success(2));
Try<List<Integer>> list = Try.sequence(tries); // → Success([1, 2])

Stream collectors

Try.toList() and Try.partitioningBy() are stream Collector counterparts to sequence — they operate on a Stream<Try<V>> instead of a list.

MethodReturnsSemantics
Try.toList()Try<List<V>>Returns first Failure (upstream fully traversed)
Try.partitioningBy()Try.Partition<V>Collect all — splits successes() and failures()

Try.Partition<V> is a record with two unmodifiable lists.

Important: toList() always consumes the entire stream — the Collector contract has no early-termination mechanism. Use sequence when you need true fail-fast behaviour on large or expensive streams. The intentional difference between toList() and sequence is documented in ADR-016 — Try.toList() collector consumes the entire stream while sequence is fail-fast.

// ── Try.toList() — returns first Failure (stream still fully traversed) ──────
Try<List<Integer>> parsed =
Stream.of("1", "2", "3")
.map(s -> Try.of(() -> Integer.parseInt(s)))
.collect(Try.toList());
// → Success([1, 2, 3])
Try<List<Integer>> broken =
Stream.of("1", "bad", "3")
.map(s -> Try.of(() -> Integer.parseInt(s)))
.collect(Try.toList());
// → Failure(NumberFormatException) — "3" is still parsed; collector returns first Failure
// Real-world: parse all config values — returns first parse error encountered
// Note: all keys are parsed regardless; do not rely on early termination for
// expensive or side-effecting upstream work.
Try<List<Duration>> timeouts = configKeys.stream()
.map(key -> Try.of(() -> Duration.parse(config.get(key))))
.collect(Try.toList());
// ── Try.partitioningBy() — process all, split successes from failures ─────────
Try.Partition<Path> result = filePaths.stream()
.map(path -> Try.of(() -> validateFile(path)))
.collect(Try.partitioningBy());
List<Path> valid = result.successes(); // files that passed validation
List<Throwable> errors = result.failures(); // exceptions from invalid files
// Real-world: import as many files as possible; log failures without aborting
importService.bulkProcess(result.successes());
result.failures().forEach(ex -> log.error("Skipping file: {}", ex.getMessage()));

Collector facade: Tries

Tries is a single discoverable entry point for all Stream<Try<V>> collectors — analogous to java.util.stream.Collectors. Import dmx.fun.Tries 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.

MethodReturnsSemantics
Tries.toList()Try<List<V>>Delegates to Try.toList()
Tries.partitioning()Tries.Partition<V>Delegates to Try.partitioningBy()

Tries.Partition is a thin wrapper that delegates to Try.Partition. Call toTryPartition() to obtain the underlying Try.Partition<V> when needed.

// ── Tries.toList() — same as Try.toList(), single import entry point ─────────
Try<List<Integer>> all = Stream.of("10", "20", "30")
.map(s -> Try.of(() -> Integer.parseInt(s)))
.collect(Tries.toList());
// → Success([10, 20, 30])
// ── Tries.partitioning() — collect all results, split by outcome ─────────────
Tries.Partition<byte[]> partition = urls.stream()
.map(url -> Try.of(() -> httpClient.fetch(url)))
.collect(Tries.partitioning());
partition.successes().forEach(body -> indexer.ingest(body));
partition.failures().forEach(ex -> metrics.recordError(ex));
// Bridge to Try.Partition when needed
Try.Partition<byte[]> tp = partition.toTryPartition();

Null handling

Try<Void> is a special case produced by Try.run(). Its success value is null. This has one important consequence: toOption() always returns None for a Try<Void>, even on success — use isSuccess() or fold() instead.

sequence, traverse, toList(), and Try.Partition all use Collections.unmodifiableList internally (not List.copyOf), so null elements in successful results are preserved. This implementation choice is documented in ADR-009 — unmodifiableList instead of List.copyOf in Try.

This asymmetry with Result.Ok — which rejects null — is a deliberate design decision documented in ADR-004 — Try allows Success(null); Result.Ok rejects null.

// Try.run() wraps a void computation — the success value is null
Try<Void> written = Try.run(() -> Files.writeString(path, content));
written.isSuccess(); // true
written.getOrNull(); // null — do NOT use toOption() here; it always returns None
// Use isSuccess() or fold() to observe a run() outcome
boolean ok = written.isSuccess();
String msg = written.fold(_ -> "written", ex -> "failed: " + ex.getMessage());
// sequence preserves null values (uses Collections.unmodifiableList, not List.copyOf)
Try<List<String>> result = Try.sequence(
Stream.of(Try.success("a"), Try.success(null), Try.success("c"))
);
// → Success(["a", null, "c"])

Interoperability

ConversionMethodNotes
TryOption<V>tryVal.toOption()Failure and Success(null) both → None
TryResult<V, Throwable>tryVal.toResult()Keeps the exception as a typed error
TryResult<V, E>tryVal.toResult(errorMapper)Maps exception to a domain error type
TryEither<Throwable, V>tryVal.toEither()Throws NullPointerException on Success(null)
TryOptional<V>tryVal.toOptional()Failure and Success(null) both → Optional.empty()
TryStream<V>tryVal.stream()Single-element on Success, empty on Failure
TryCompletableFuture<V>tryVal.toFuture()Already-completed future
OptionTryTry.fromOption(opt, exceptionSupplier)NoneFailure(supplied exception)
OptionalTryTry.fromOptional(optional, exceptionSupplier)emptyFailure(supplied exception)
Either<L, R>TryTry.fromEither(either, leftMapper)Accepts Either<? extends L, ? extends R> (covariant); left(l)Failure(leftMapper(l))
Result<V, Throwable>TryTry.fromResult(result)Accepts Result<? extends V, ? extends Throwable> (covariant)
Result<V, E>Tryresult.toTry(errorMapper)Maps error to a Throwable
CompletableFuture<V>TryTry.fromFuture(future)Blocks; unwraps CompletionException
Try<String> t = Try.of(() -> Files.readString(path));
// Try → Option (discards the exception; Success(null) also maps to None)
Option<String> opt = t.toOption();
// Try → Result<V, Throwable> (keeps the exception as a typed error)
Result<String, Throwable> raw = t.toResult();
// Try → Result<V, E> with error mapping
Result<String, AppError> typed = t.toResult(ex -> new AppError(ex.getMessage()));
// Try → Either<Throwable, V> (throws NPE on Success(null))
Either<Throwable, String> either = t.toEither();
// Try → Optional<V> (Failure and Success(null) → Optional.empty())
Optional<String> javaOpt = t.toOptional();
// Try → Stream<V> (empty on Failure, single element on Success)
t.stream().forEach(System.out::println);
// Try → CompletableFuture<V> (already-completed future)
CompletableFuture<String> future = t.toFuture();
// ── From other types ──────────────────────────────────────────────────────────
// Option → Try
Option<String> option = Option.some("hello");
Try<String> fromOpt = Try.fromOption(option, () -> new NoSuchElementException("missing"));
// Optional → Try
Optional<String> optional = Optional.of("hello");
Try<String> fromOptional = Try.fromOptional(optional, () -> new NoSuchElementException("missing"));
// Either<L, R> → Try (left value mapped to a Throwable)
Either<String, Integer> left = Either.left("not found");
Either<String, Integer> right = Either.right(42);
Try<Integer> fromLeft = Try.fromEither(left, IllegalArgumentException::new);
// Try.failure(new IllegalArgumentException("not found"))
Try<Integer> fromRight = Try.fromEither(right, IllegalArgumentException::new);
// Try.success(42)
// Result<V, Throwable> → Try
Result<String, IOException> result = Result.err(new IOException("oops"));
Try<String> fromResult = Try.fromResult(result);
// CompletableFuture → Try (blocks until complete; unwraps CompletionException)
Try<String> fromFuture = Try.fromFuture(CompletableFuture.completedFuture("done"));

See the Combining Types page for the full conversion matrix and composition patterns.

Try vs Result vs checked exceptions

Checked exceptionsTry<V>Result<V, E>
Error typeDeclared throws clauseAlways ThrowableAny domain type
Forces handlingYes — compile timeYes — via APIYes — via API and switch patterns
Branch on error subtypeVia catch clausesVia instanceof / recover(Class)Via sealed subtype and switch patterns
Interop with throwing codeNativeExcellent — Try.of(() -> ...)Indirect — wrap with Try first
Best forInternal APIs you controlWrapping legacy/third-party codeDomain logic with typed failure modes

Time-bounded execution — Try.withTimeout

Try.withTimeout(Duration, CheckedSupplier) runs the supplier on a virtual thread and enforces the time limit via Future.get(timeout, unit).

  • If the computation completes in timeSuccess(value).
  • If the deadline is exceeded → the virtual thread is interrupted and a Failure(TimeoutException("Operation timed out after Xms")) is returned.
  • If the computation throws before the deadlineFailure(originalCause).

Requires Java 21+ (virtual threads). Because Try is an immutable value type (already computed), timeout applies at creation time — use the static factory rather than trying to retroactively time-limit an existing Try. The choice to use Thread.ofVirtual() over a platform-thread ExecutorService or a shared CompletableFuture pool is documented in ADR-013 — Try.withTimeout uses virtual threads (Thread.ofVirtual()).

// Static factory — enforce a time limit from the start
Try<Response> result = Try.withTimeout(
Duration.ofSeconds(5),
() -> httpClient.get(url)
);
// Exceeded deadline → Failure(TimeoutException("Operation timed out after 100000000ns"))
Try<String> timedOut = Try.withTimeout(
Duration.ofMillis(100),
() -> { Thread.sleep(10_000); return "never"; }
);
timedOut.isFailure(); // true
timedOut.getCause(); // TimeoutException: "Operation timed out after 100000000ns"
// Composes naturally with the rest of the API
Try.withTimeout(Duration.ofSeconds(3), () -> fetchUser(id))
.recover(TimeoutException.class, ex -> User.anonymous())
.flatMap(user -> validateActive(user))
.onSuccess(user -> cache.put(id, user));

Common pitfalls

Calling get() without checking state

get() throws NoSuchElementException on Failure. Use getOrElse, fold, or pattern matching instead.

// Bad: throws NoSuchElementException if Failure
int n = Try.of(() -> Integer.parseInt(input)).get();
// Good: safe extraction
int n = Try.of(() -> Integer.parseInt(input)).getOrElse(0);
// Good: handle both tracks explicitly
int n = Try.of(() -> Integer.parseInt(input))
.fold(value -> value, ex -> { log.warn("bad input", ex); return 0; });

Using Try when Result is the right tool

Try discards the error domain — callers can only catch Throwable. If you own the error type and callers need to branch on specific subtypes, use Result. Reserve Try for the boundary where third-party code throws.

// Bad: Try buries the error domain — callers can't branch on error type
Try<User> findUser(long id) {
return Try.of(() -> userRepo.find(id));
}
// Good: when you own the error type, use Result
Result<User, UserError> findUser(long id) {
return userRepo.find(id)
.map(Result::<User, UserError>ok)
.orElse(Result.err(UserError.NOT_FOUND));
}
// Good: use Try only at the boundary where legacy code throws
Result<User, UserError> findUser(long id) {
return Try.of(() -> legacyRepo.find(id))
.toResult(ex -> new UserError(ex.getMessage()));
}

Using toOption() after Try.run()

Try.run() produces Success(null). Converting to Option via toOption() always returns None — the null success value is silently discarded. Use isSuccess() or fold() to observe the outcome of a run() call.

Real-world example

A file-read → parse → validate → save pipeline. Each step may throw; Try captures the exception at every boundary without nested try/catch blocks.

// File → parse → validate → save pipeline
// Each step may throw; Try captures the exception at every boundary.
Try<ReportId> saveReport(Path path) {
return Try.of(() -> Files.readString(path)) // IOException
.map(text -> Json.parse(text)) // JsonException
.flatMap(json -> Try.of(() -> validate(json))) // ValidationException
.flatMap(valid -> Try.of(() -> reportRepo.save(valid))) // SQLException
.map(Report::id);
}
// At the call site, fold handles both outcomes
saveReport(uploadedPath).fold(
id -> Response.ok("Saved report " + id),
ex -> switch (ex) {
case IOException e -> Response.error(503, "Storage unavailable");
case JsonException e -> Response.error(400, "Invalid JSON");
case ValidationException e -> Response.error(422, e.getMessage());
default -> Response.error(500, "Unexpected error");
}
);

Every step propagates its Failure automatically. At the call site, fold with a pattern-matched switch handles each exception type individually.