π¦ Vanilla Result
Railway-oriented programming without the framework baggage
The Problem
Youβve seen the enterprise codebases. The try/catch pyramids. The swallowed exceptions. The throws Exception
methods that make your API a minefield.
// The exception nightmare
public User getUserById(String id) throws UserNotFoundException, DatabaseException, ValidationException {
try {
validateId(id);
return database.findUser(id);
} catch (ValidationException e) {
logger.error("Validation failed", e);
throw e;
} catch (SQLException e) {
logger.error("Database error", e);
throw new DatabaseException("Failed to fetch user", e);
}
}
So you reached for Vavr (50,000+ lines of Scala-inspired Java). Or maybe Arrow-kt (for those who dream in Kotlin). Perhaps you considered Resilience4j just for its Try
type.
All you wanted was to treat errors as data. But instead, you got:
- π¦ Another 2-5MB JAR dependency
- π 200 pages of documentation about monadic composition
- π€― Explaining to your team what βleft-biased Eitherβ means
- π Framework overhead in your hot paths
The Solution
157 lines of modern Java. Thatβs it. Copy, paste, own it forever.
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public sealed interface Result<T, E> permits Result.Success, Result.Failure {
record Success<T, E>(T value) implements Result<T, E> {}
record Failure<T, E>(E error) implements Result<T, E> {}
// Factory methods
static <T, E> Result<T, E> success(T value) {
return new Success<>(value);
}
static <T, E> Result<T, E> failure(E error) {
return new Failure<>(error);
}
static <T, E extends Exception> Result<T, E> of(ThrowingSupplier<T, E> supplier) {
try {
return success(supplier.get());
} catch (Exception e) {
@SuppressWarnings("unchecked")
E error = (E) e;
return failure(error);
}
}
// Predicates
default boolean isSuccess() {
return this instanceof Success<T, E>;
}
default boolean isFailure() {
return this instanceof Failure<T, E>;
}
// Transform success value
default <U> Result<U, E> map(Function<? super T, ? extends U> mapper) {
return switch (this) {
case Success<T, E>(var value) -> new Success<>(mapper.apply(value));
case Failure<T, E>(var error) -> new Failure<>(error);
};
}
// Transform error value
default <F> Result<T, F> mapError(Function<? super E, ? extends F> mapper) {
return switch (this) {
case Success<T, E>(var value) -> new Success<>(value);
case Failure<T, E>(var error) -> new Failure<>(mapper.apply(error));
};
}
// Chain operations that return Result
default <U> Result<U, E> flatMap(Function<? super T, ? extends Result<U, E>> mapper) {
return switch (this) {
case Success<T, E>(var value) -> mapper.apply(value);
case Failure<T, E>(var error) -> new Failure<>(error);
};
}
// Pattern match with handlers
default <U> U fold(Function<? super T, ? extends U> onSuccess,
Function<? super E, ? extends U> onFailure) {
return switch (this) {
case Success<T, E>(var value) -> onSuccess.apply(value);
case Failure<T, E>(var error) -> onFailure.apply(error);
};
}
// Recover from error with fallback value
default T recover(Function<? super E, ? extends T> recovery) {
return switch (this) {
case Success<T, E>(var value) -> value;
case Failure<T, E>(var error) -> recovery.apply(error);
};
}
// Recover from error with alternative Result
default Result<T, E> recoverWith(Function<? super E, ? extends Result<T, E>> recovery) {
return switch (this) {
case Success<T, E> s -> s;
case Failure<T, E>(var error) -> recovery.apply(error);
};
}
// Get value or throw
default T orElseThrow() {
return switch (this) {
case Success<T, E>(var value) -> value;
case Failure<T, E>(var error) -> {
if (error instanceof Exception e) {
throw new RuntimeException(e);
}
throw new RuntimeException("Result failed with error: " + error);
}
};
}
// Get value or use default
default T orElse(T defaultValue) {
return switch (this) {
case Success<T, E>(var value) -> value;
case Failure<T, E> ignored -> defaultValue;
};
}
// Get value or compute default
default T orElseGet(Supplier<? extends T> supplier) {
return switch (this) {
case Success<T, E>(var value) -> value;
case Failure<T, E> ignored -> supplier.get();
};
}
// Peek at success value (for side effects)
default Result<T, E> peek(Consumer<? super T> consumer) {
if (this instanceof Success<T, E>(var value)) {
consumer.accept(value);
}
return this;
}
// Peek at error value (for side effects)
default Result<T, E> peekError(Consumer<? super E> consumer) {
if (this instanceof Failure<T, E>(var error)) {
consumer.accept(error);
}
return this;
}
// Functional interface for throwing operations
@FunctionalInterface
interface ThrowingSupplier<T, E extends Exception> {
T get() throws E;
}
}
Usage Examples
Basic Error Handling
Exception Hell
Complexity: 8/10public User getUserById(String id) throws UserNotFoundException, DatabaseException {
try {
validateId(id);
return database.findUser(id);
} catch (ValidationException e) {
throw new DatabaseException("Invalid ID", e);
} catch (SQLException e) {
throw new DatabaseException("Database error", e);
}
}
// Caller has to deal with exceptions
try {
User user = getUserById("123");
processUser(user);
} catch (UserNotFoundException e) {
logger.error("User not found", e);
return defaultUser();
} catch (DatabaseException e) {
logger.error("Database error", e);
throw new RuntimeException(e);
}
Vanilla DI
Complexity: 0/10public Result<User, String> getUserById(String id) {
return validateId(id)
.flatMap(validId -> database.findUser(validId));
}
// Caller handles errors functionally
getUserById("123")
.peek(user -> logger.info("Found user: {}", user.name()))
.peekError(error -> logger.error("Failed: {}", error))
.map(this::processUser)
.orElse(defaultUser());
Witness the dramatic transformation from exception pyramids to functional error handling. No try/catch archaeology required!
Railway-Oriented Programming
Try/Catch Chaos
Complexity: 9/10public OrderConfirmation processOrder(OrderRequest request)
throws ValidationException, InventoryException, PaymentException, ShipmentException {
try {
ValidOrder order = validateOrder(request);
try {
OrderWithInventory withInventory = checkInventory(order);
try {
OrderWithPayment withPayment = processPayment(withInventory);
try {
Shipment shipment = createShipment(withPayment);
return generateConfirmation(shipment);
} catch (ShipmentException e) {
logger.error("Shipment failed", e);
throw e;
}
} catch (PaymentException e) {
logger.error("Payment failed", e);
throw e;
}
} catch (InventoryException e) {
logger.error("Inventory check failed", e);
throw e;
}
} catch (ValidationException e) {
logger.error("Validation failed", e);
throw e;
}
}
Vanilla DI
Complexity: 0/10public Result<OrderConfirmation, String> processOrder(OrderRequest request) {
return validateOrder(request)
.flatMap(this::checkInventory)
.flatMap(this::processPayment)
.flatMap(this::createShipment)
.map(this::generateConfirmation)
.peekError(error -> logger.error("Order failed: {}", error));
}
// Each step returns Result<T, String>
private Result<ValidOrder, String> validateOrder(OrderRequest request) {
if (request.items().isEmpty()) {
return Result.failure("Order has no items");
}
return Result.success(new ValidOrder(request));
}
private Result<OrderWithInventory, String> checkInventory(ValidOrder order) {
return inventoryService.check(order.items())
.map(inventory -> new OrderWithInventory(order, inventory));
}
Chain operations that can fail without drowning in nested try/catch blocks. Each operation flows seamlessly to the next!
Converting Exceptions to Results
Exception Wrapping
Complexity: 7/10Config loadConfig() {
String configJson;
try {
configJson = Files.readString(Path.of("config.json"));
} catch (IOException e) {
logger.error("Failed to load config", e);
return defaultConfig();
}
Config config;
try {
config = parseConfig(configJson);
} catch (JsonException e) {
logger.error("Failed to parse config", e);
return defaultConfig();
}
logger.info("Loaded config: {}", config);
return config;
}
Vanilla DI
Complexity: 0/10Config loadConfig() {
return Result.of(() -> Files.readString(Path.of("config.json")))
.map(this::parseConfig)
.recover(error -> defaultConfig())
.peek(config -> logger.info("Loaded config: {}", config));
}
Transform exception-throwing APIs into functional Results without the ceremony. One method call replaces try/catch boilerplate!
Pattern Matching with Fold
If/Else Chains
Complexity: 6/10String getUserMessage(String userId) {
try {
User user = getUserById(userId);
return "Welcome, " + user.name();
} catch (UserNotFoundException e) {
return "Error: User not found";
} catch (DatabaseException e) {
return "Error: Database error - " + e.getMessage();
} catch (Exception e) {
return "Error: Unknown error";
}
}
int getPaymentStatusCode(Order order) {
try {
processPayment(order);
return 200;
} catch (PaymentDeclinedException e) {
return 402;
} catch (InsufficientFundsException e) {
return 402;
} catch (NetworkException e) {
return 503;
} catch (Exception e) {
return 500;
}
}
Vanilla DI
Complexity: 0/10String getUserMessage(String userId) {
return getUserById(userId).fold(
user -> "Welcome, " + user.name(),
error -> "Error: " + error
);
}
int getPaymentStatusCode(Order order) {
return processPayment(order).fold(
success -> 200,
error -> switch (error) {
case PaymentDeclined pd -> 402;
case InsufficientFunds inf -> 402;
case NetworkError ne -> 503;
default -> 500;
}
);
}
Replace verbose if/else chains with elegant pattern matching. Java 25 switch expressions make error handling beautiful!
Error Recovery Strategies
Nested Try/Catch
Complexity: 8/10User getUser(String id) {
// Try primary source
try {
return getUserById(id);
} catch (UserNotFoundException e) {
// Try cache fallback
try {
return getUserFromCache(id);
} catch (CacheException ce) {
// Use anonymous fallback
logger.warn("Failed to get user, using anonymous", e);
return User.anonymous();
}
} catch (DatabaseException e) {
logger.error("Database error", e);
throw new RuntimeException(e);
}
}
Config loadConfiguration() {
// Try file
try {
return loadFromFile();
} catch (IOException e) {
// Try environment
try {
return loadFromEnvironment();
} catch (ConfigException ce) {
// Try defaults
try {
return loadFromDefaults();
} catch (Exception de) {
throw new RuntimeException("No config source available", de);
}
}
}
}
Vanilla DI
Complexity: 0/10User getUser(String id) {
return getUserById(id)
.recoverWith(error -> getUserFromCache(id))
.recover(error -> User.anonymous());
}
Config loadConfiguration() {
return loadFromFile()
.recoverWith(err -> loadFromEnvironment())
.recoverWith(err -> loadFromDefaults())
.orElseThrow();
}
Chain multiple fallback strategies elegantly. No more nested try/catch pyramids when you have multiple recovery options!
Why Vanilla Result?
Just 157 Lines
Vavr: 50,000+ lines. Arrow-kt: Requires Kotlin. You: 157 lines you can read in 5 minutes.
Zero Dependencies
No Maven coordinates. No version conflicts. No transitive dependency surprises. Just Java.
Debug Friendly
Stack traces point to YOUR code. No framework internals. Your IDE autocompletes everything.
Modern Java
Sealed interfaces, records, pattern matching, switch expressions. Uses the Java you're already paying Oracle for.
Requirements
- Java 21+ for sealed interfaces, records, and pattern matching
- Java 25 recommended for latest pattern matching enhancements
- Thatβs it. No frameworks, no libraries, no build plugins.
Installation
- Copy the code above
- Paste into your project (e.g.,
src/main/java/yourpackage/Result.java
) - Start using it
No Maven coordinates, no Gradle dependencies, no framework configuration.
Congratulations! You now have a production-ready Result type and you understand every line of it.
Real-World Benefits
Before Vanilla Result
π Pain Points- π΄ Exception handling scattered across codebase
- π΄ Silent failures from swallowed exceptions
- π΄ Unclear API contracts (
throws Exception
?) - π΄ Framework dependency for simple error handling
After Vanilla Result
β¨ Benefits- β Explicit error handling in type signatures
- β Compiler-enforced error handling
- β
Clear API contracts (
Result<User, UserError>
) - β Complete control over your error handling code
Part of the Vanilla Libraries collection
Because sometimes the best framework is 157 lines of code you actually understand