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 as-is; wraps Error/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.

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])

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 uses Collections.unmodifiableList internally (not List.copyOf), so null elements in successful results are preserved.

// 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

ConversionMethod
TryOptiontryVal.toOption()
TryResult<V, Throwable>tryVal.toResult()
TryResult<V, E>tryVal.toResult(errorMapper)
TryEither<Throwable, V>tryVal.toEither()
TryStream<V>tryVal.stream()
OptionTryoption.toTry(exceptionSupplier)
ResultTryresult.toTry(errorMapper)
Try<String> t = Try.of(() -> Files.readString(path));
// Try → Option (discards the exception)
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>
Either<Throwable, String> either = t.toEither();
// Try → Stream<V> (empty on Failure, single element on Success)
t.stream().forEach(System.out::println);
// Option → Try
Option<String> option = Option.some("hello");
Try<String> fromOpt = option.toTry(() -> new NoSuchElementException("missing"));

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.

// 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.