Either
, Try
Patterns (Custom Implementations)Traditional error handling in Java primarily relies on exceptions and sometimes returning null
to signal failure. While exceptions are powerful, they come with several drawbacks: they disrupt normal control flow, are often costly to create, and encourage imperative “try-catch” blocks scattered throughout the code. Returning null
to indicate errors can lead to subtle bugs and NullPointerException
s if not handled carefully.
Functional programming offers more expressive alternatives to model computations that may fail, avoiding exceptions as control flow and providing better composability. Two common patterns inspired by functional languages are Either
and Try
monads. They encapsulate the result of a computation that can succeed or fail, making error handling explicit and fluent.
Either<L, R>
represents a value of two possible types:
This design explicitly separates success from failure, forcing the programmer to handle both cases without exceptions.
Try<T>
models computations that can throw exceptions:
Success
) or an exception (Failure
), allowing chaining operations that may fail without immediate exception throwing.Either
public abstract class Either<L, R> {
public abstract boolean isRight();
public abstract boolean isLeft();
public abstract L getLeft();
public abstract R getRight();
public static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
public static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
private static final class Right<L, R> extends Either<L, R> {
private final R value;
Right(R value) { this.value = value; }
public boolean isRight() { return true; }
public boolean isLeft() { return false; }
public L getLeft() { throw new IllegalStateException("No left value"); }
public R getRight() { return value; }
}
private static final class Left<L, R> extends Either<L, R> {
private final L value;
Left(L value) { this.value = value; }
public boolean isRight() { return false; }
public boolean isLeft() { return true; }
public L getLeft() { return value; }
public R getRight() { throw new IllegalStateException("No right value"); }
}
}
Usage example:
Either<String, Integer> parseInt(String s) {
try {
return Either.right(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Either.left("Invalid number: " + s);
}
}
Either<String, Integer> result = parseInt("123");
if (result.isRight()) {
System.out.println("Parsed value: " + result.getRight());
} else {
System.out.println("Error: " + result.getLeft());
}
public abstract class Either<L, R> {
public abstract boolean isRight();
public abstract boolean isLeft();
public abstract L getLeft();
public abstract R getRight();
public static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
public static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
private static final class Right<L, R> extends Either<L, R> {
private final R value;
Right(R value) { this.value = value; }
public boolean isRight() { return true; }
public boolean isLeft() { return false; }
public L getLeft() { throw new IllegalStateException("No left value"); }
public R getRight() { return value; }
}
private static final class Left<L, R> extends Either<L, R> {
private final L value;
Left(L value) { this.value = value; }
public boolean isRight() { return false; }
public boolean isLeft() { return true; }
public L getLeft() { return value; }
public R getRight() { throw new IllegalStateException("No right value"); }
}
// Usage example in main:
public static void main(String[] args) {
Either<String, Integer> result1 = parseInt("123");
printResult(result1);
Either<String, Integer> result2 = parseInt("abc");
printResult(result2);
}
static Either<String, Integer> parseInt(String s) {
try {
return Either.right(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Either.left("Invalid number: " + s);
}
}
static void printResult(Either<String, Integer> result) {
if (result.isRight()) {
System.out.println("Parsed value: " + result.getRight());
} else {
System.out.println("Error: " + result.getLeft());
}
}
}
Try
public abstract class Try<T> {
public abstract boolean isSuccess();
public abstract T get() throws Exception;
public abstract Exception getException();
public static <T> Try<T> of(CheckedSupplier<T> supplier) {
try {
return new Success<>(supplier.get());
} catch (Exception e) {
return new Failure<>(e);
}
}
public interface CheckedSupplier<T> {
T get() throws Exception;
}
private static final class Success<T> extends Try<T> {
private final T value;
Success(T value) { this.value = value; }
public boolean isSuccess() { return true; }
public T get() { return value; }
public Exception getException() { return null; }
}
private static final class Failure<T> extends Try<T> {
private final Exception exception;
Failure(Exception exception) { this.exception = exception; }
public boolean isSuccess() { return false; }
public T get() throws Exception { throw exception; }
public Exception getException() { return exception; }
}
}
Usage example:
Try<Integer> result = Try.of(() -> Integer.parseInt("abc"));
if (result.isSuccess()) {
System.out.println("Parsed: " + result.get());
} else {
System.out.println("Failed with: " + result.getException().getMessage());
}
public class TryExample {
public static void main(String[] args) {
Try<Integer> result = Try.of(() -> Integer.parseInt("abc"));
if (result.isSuccess()) {
try {
System.out.println("Parsed: " + result.get());
} catch (Exception e) {
// This shouldn't happen because we already checked isSuccess()
}
} else {
System.out.println("Failed with: " + result.getException().getMessage());
}
}
public static abstract class Try<T> {
public abstract boolean isSuccess();
public abstract T get() throws Exception;
public abstract Exception getException();
public static <T> Try<T> of(CheckedSupplier<T> supplier) {
try {
return new Success<>(supplier.get());
} catch (Exception e) {
return new Failure<>(e);
}
}
public interface CheckedSupplier<T> {
T get() throws Exception;
}
private static final class Success<T> extends Try<T> {
private final T value;
Success(T value) {
this.value = value;
}
public boolean isSuccess() {
return true;
}
public T get() {
return value;
}
public Exception getException() {
return null;
}
}
private static final class Failure<T> extends Try<T> {
private final Exception exception;
Failure(Exception exception) {
this.exception = exception;
}
public boolean isSuccess() {
return false;
}
public T get() throws Exception {
throw exception;
}
public Exception getException() {
return exception;
}
}
}
}
Either
and Try
By adopting Either
and Try
patterns in Java, you gain a more functional and robust way to handle errors that avoids the pitfalls of exceptions and nulls. These patterns encourage explicit error propagation, reduce boilerplate try-catch blocks, and foster composable, maintainable code that clearly distinguishes success from failure.
In functional programming, composing small, reusable functions is a key principle. However, when these functions can fail—returning errors instead of plain values—composition becomes more challenging. Traditional exception handling often breaks the flow and requires verbose try-catch blocks, making the code harder to read and maintain.
Using functional error handling constructs like Either
or Try
(introduced in the previous section) enables elegant composition by representing computations that may succeed or fail explicitly. This allows chaining operations safely, propagating errors automatically, and preserving error context without throwing exceptions.
Suppose you have functions that return Either<L, R>
or Try<T>
. Composing them requires passing the successful result from one function to the next while short-circuiting the pipeline if an error occurs.
Either
for composition with flatMap
Recall our custom Either<L, R>
implementation from the previous section. To compose functions, we add a flatMap
method:
public abstract class Either<L, R> {
// ... existing methods ...
public abstract <U> Either<L, U> flatMap(Function<? super R, Either<L, U>> mapper);
// Inside Right:
private static final class Right<L, R> extends Either<L, R> {
// ...
public <U> Either<L, U> flatMap(Function<? super R, Either<L, U>> mapper) {
return mapper.apply(value);
}
}
// Inside Left:
private static final class Left<L, R> extends Either<L, R> {
// ...
public <U> Either<L, U> flatMap(Function<? super R, Either<L, U>> mapper) {
return (Either<L, U>) this; // propagate the error without calling mapper
}
}
}
Either
Either<String, Integer> parse(String s) {
try {
return Either.right(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Either.left("Invalid number: " + s);
}
}
Either<String, Integer> reciprocal(int x) {
if (x == 0) {
return Either.left("Division by zero");
} else {
return Either.right(1 / x);
}
}
public void composeExample() {
Either<String, Integer> result = parse("10")
.flatMap(this::reciprocal);
result.isRight()
? System.out.println("Result: " + result.getRight())
: System.out.println("Error: " + result.getLeft());
}
Here, if parse
fails, the error propagates, and reciprocal
is never called, short-circuiting the chain. If both succeed, the final result is printed.
Try
for safe compositionSimilarly, a Try
type can have a flatMap
method to chain computations:
public abstract class Try<T> {
// ... existing methods ...
public abstract <U> Try<U> flatMap(Function<? super T, Try<U>> mapper);
// Success implementation:
private static final class Success<T> extends Try<T> {
// ...
public <U> Try<U> flatMap(Function<? super T, Try<U>> mapper) {
try {
return mapper.apply(value);
} catch (Exception e) {
return new Failure<>(e);
}
}
}
// Failure implementation:
private static final class Failure<T> extends Try<T> {
// ...
public <U> Try<U> flatMap(Function<? super T, Try<U>> mapper) {
return (Try<U>) this;
}
}
}
Example usage:
Try<Integer> parseTry(String s) {
return Try.of(() -> Integer.parseInt(s));
}
Try<Integer> reciprocalTry(int x) {
return x == 0
? new Try.Failure<>(new ArithmeticException("Division by zero"))
: new Try.Success<>(1 / x);
}
public void tryComposeExample() {
Try<Integer> result = parseTry("0")
.flatMap(this::reciprocalTry);
if (result.isSuccess()) {
System.out.println("Result: " + result.get());
} else {
System.out.println("Failed: " + result.getException().getMessage());
}
}
Both Either
and Try
support recovery methods:
Either
can provide fallback values via a method like orElse
or fold
.Try
typically has recover
or recoverWith
to replace failures with a default or alternate success.Example recovery with Try
:
Try<Integer> safeResult = parseTry("abc")
.flatMap(this::reciprocalTry)
.recover(ex -> 0); // fallback to 0 on failure
Either
and Try
monads.flatMap
(or equivalent) lets you chain computations, automatically short-circuiting on errors.By adopting these patterns, Java developers gain powerful tools for robust error propagation and composition, moving beyond the pitfalls of exceptions and nulls.
Validating user input is a common task that often involves multiple checks on different fields. Traditional validation approaches either throw exceptions on the first failure or accumulate errors in an ad hoc manner. Functional error handling patterns like Either
allow us to build composable validation pipelines that clearly separate success and failure cases, enabling us to collect all errors without exceptions.
In this example, we'll create a simple user input validator that checks a username and age, accumulating errors if either validation fails. We’ll use a custom Either
type that supports error accumulation in a List<String>
on the left side.
Either
supporting error accumulationimport java.util.*;
import java.util.function.Function;
abstract class Either<L, R> {
public abstract boolean isRight();
public abstract R getRight();
public abstract L getLeft();
// Factory methods
public static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
public static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
// Combine two Eithers accumulating errors (assuming L is List<String>)
public Either<List<String>, R> combine(Either<List<String>, R> other,
Function<R, R> combineFunc) {
if (this.isRight() && other.isRight()) {
return right(combineFunc.apply(other.getRight()));
} else {
List<String> errors = new ArrayList<>();
if (this.isLeft()) errors.addAll((List<String>)this.getLeft());
if (other.isLeft()) errors.addAll((List<String>)other.getLeft());
return left(errors);
}
}
// Inner classes
private static final class Right<L, R> extends Either<L, R> {
private final R value;
Right(R value) { this.value = value; }
public boolean isRight() { return true; }
public R getRight() { return value; }
public L getLeft() { throw new NoSuchElementException("No Left value"); }
}
private static final class Left<L, R> extends Either<L, R> {
private final L value;
Left(L value) { this.value = value; }
public boolean isRight() { return false; }
public R getRight() { throw new NoSuchElementException("No Right value"); }
public L getLeft() { return value; }
}
}
class User {
final String username;
final int age;
User(String username, int age) {
this.username = username;
this.age = age;
}
@Override
public String toString() {
return "User{username='" + username + "', age=" + age + "}";
}
}
public class UserValidationExample {
static Either<List<String>, String> validateUsername(String username) {
List<String> errors = new ArrayList<>();
if (username == null || username.isEmpty()) {
errors.add("Username cannot be empty");
}
if (username != null && username.length() < 3) {
errors.add("Username must be at least 3 characters");
}
return errors.isEmpty() ? Either.right(username) : Either.left(errors);
}
static Either<List<String>, Integer> validateAge(int age) {
if (age < 18) {
return Either.left(Collections.singletonList("Age must be at least 18"));
}
return Either.right(age);
}
static Either<List<String>, User> validateUser(String username, int age) {
Either<List<String>, String> validUsername = validateUsername(username);
Either<List<String>, Integer> validAge = validateAge(age);
if (validUsername.isRight() && validAge.isRight()) {
return Either.right(new User(validUsername.getRight(), validAge.getRight()));
}
// Accumulate errors from both validations
List<String> allErrors = new ArrayList<>();
if (validUsername.isLeft()) allErrors.addAll(validUsername.getLeft());
if (validAge.isLeft()) allErrors.addAll(validAge.getLeft());
return Either.left(allErrors);
}
public static void main(String[] args) {
// Test cases
Either<List<String>, User> user1 = validateUser("Al", 20);
Either<List<String>, User> user2 = validateUser("Alice", 17);
Either<List<String>, User> user3 = validateUser("", 15);
Either<List<String>, User> user4 = validateUser("Bob", 25);
printResult(user1);
printResult(user2);
printResult(user3);
printResult(user4);
}
static void printResult(Either<List<String>, User> result) {
if (result.isRight()) {
System.out.println("Validation succeeded: " + result.getRight());
} else {
System.out.println("Validation failed with errors:");
result.getLeft().forEach(err -> System.out.println(" - " + err));
}
}
}
import java.util.*;
import java.util.function.Function;
// Either abstraction
abstract class Either<L, R> {
public abstract boolean isRight();
public abstract R getRight();
public abstract L getLeft();
public static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
public static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
private static final class Right<L, R> extends Either<L, R> {
private final R value;
Right(R value) { this.value = value; }
public boolean isRight() { return true; }
public R getRight() { return value; }
public L getLeft() { throw new NoSuchElementException("No Left value"); }
}
private static final class Left<L, R> extends Either<L, R> {
private final L value;
Left(L value) { this.value = value; }
public boolean isRight() { return false; }
public R getRight() { throw new NoSuchElementException("No Right value"); }
public L getLeft() { return value; }
}
}
// User class
class User {
final String username;
final int age;
User(String username, int age) {
this.username = username;
this.age = age;
}
@Override
public String toString() {
return "User{username='" + username + "', age=" + age + "}";
}
}
// Validation and demo logic
public class UserValidationExample {
static Either<List<String>, String> validateUsername(String username) {
List<String> errors = new ArrayList<>();
if (username == null || username.isEmpty()) {
errors.add("Username cannot be empty");
}
if (username != null && username.length() < 3) {
errors.add("Username must be at least 3 characters");
}
return errors.isEmpty() ? Either.right(username) : Either.left(errors);
}
static Either<List<String>, Integer> validateAge(int age) {
if (age < 18) {
return Either.left(Collections.singletonList("Age must be at least 18"));
}
return Either.right(age);
}
static Either<List<String>, User> validateUser(String username, int age) {
Either<List<String>, String> validUsername = validateUsername(username);
Either<List<String>, Integer> validAge = validateAge(age);
if (validUsername.isRight() && validAge.isRight()) {
return Either.right(new User(validUsername.getRight(), validAge.getRight()));
}
List<String> allErrors = new ArrayList<>();
if (!validUsername.isRight()) allErrors.addAll(validUsername.getLeft());
if (!validAge.isRight()) allErrors.addAll(validAge.getLeft());
return Either.left(allErrors);
}
public static void main(String[] args) {
List<Either<List<String>, User>> testCases = Arrays.asList(
validateUser("Al", 20),
validateUser("Alice", 17),
validateUser("", 15),
validateUser("Bob", 25)
);
for (Either<List<String>, User> result : testCases) {
printResult(result);
}
}
static void printResult(Either<List<String>, User> result) {
if (result.isRight()) {
System.out.println("✅ Validation succeeded: " + result.getRight());
} else {
System.out.println("❌ Validation failed with errors:");
result.getLeft().forEach(err -> System.out.println(" - " + err));
}
}
}
Either<List<String>, T>
to encapsulate multiple errors or a valid value.validateUser
method combines results, accumulating all errors rather than failing fast.main
method demonstrates multiple input scenarios with outputs showing success or error accumulation.Validation failed with errors:
- Username must be at least 3 characters
Validation failed with errors:
- Age must be at least 18
Validation failed with errors:
- Username cannot be empty
- Age must be at least 18
Validation succeeded: User{username='Bob', age=25}
This example demonstrates how functional error handling with Either
enables expressive, composable validation pipelines that accumulate errors cleanly and improve code readability and robustness.