πŸ“¦ 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/10
public 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/10
public 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/10
public 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/10
public 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/10
Config 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/10
Config 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/10
String 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/10
String 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/10
User 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/10
User 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

  1. Copy the code above
  2. Paste into your project (e.g., src/main/java/yourpackage/Result.java)
  3. 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