Look at where the crew in the photo put the sign: not at city hall, not in a binder of known issues — at the exact point the street stops being drivable, in a shape every driver already knows how to read. The road failed, and the failure was turned into information at the site of the failure. Traffic keeps moving under the same rules it always follows; it just takes the marked branch.

Exceptions are the other design. The road fails, and the car is teleported — out of the intersection, over the rooftops, to whichever catch block happens to be listening somewhere up the stack. No sign was posted where it happened. Nothing in the street’s description said it could happen. And every driver behind you learns about the hole the same way you did.

This post is the practical case for the first design in Java: expected failures as ordinary return values, using Result and Try — what actually changes at the call site, why the compiler becomes an ally instead of a bystander, and the honest boundary where exceptions remain the right tool.


What a throw really does

Strip the familiarity away and throw is a non-local jump: it abandons the current expression, unwinds frame after frame, and resumes execution at a catch selected at runtime by type. Two consequences follow, and both are about information.

First, the signature lies by omission. BigDecimal parseAmount(String raw) reads as total — give it a string, receive a number. That it detonates on "12,50" is knowledge the type system never asked the caller to acquire; it lives in javadoc, tribal memory, or a production incident. Second, where a failure is handled is an accident of the call stack: the type picks which catch wins, but only among whichever handlers happen to be up-stack at that moment — no contract anywhere says where that is, which is why real codebases grow catch (Exception e) at the top and a log line nobody correlates — the jump made it possible to handle errors anywhere, and anywhere quietly became nowhere in particular.

Checked exceptions were Java’s attempt to fix the first problem — failure declared in the signature — and the idea was right. The mechanics did not survive composition: java.util.function.Function declares no checked exceptions, so the moment error-throwing code meets a lambda or a stream, every call site wraps into something unchecked, swallows, or leans on a checked-friendly functional interface — the CheckedSupplier family this library ships, and the only reason the Try.of lambda in the border section below compiles against a throwing call. The signature honesty was real; it just could not ride the abstractions modern Java is written in. Errors as values keep the honesty and lose the friction, because a return value composes anywhere a value does.


Put the failure in the return type

The functional move is almost embarrassingly literal: if a failure is expected, return it. A Result<Value, Error> is either Ok carrying the value or Err carrying the error, and the error type is a sealed hierarchy you design — one case per thing a caller could act on, each carrying its evidence as fields. Here is a trimmed cut of that post’s canonical TransferError, keeping its Money and AccountId domain types and adding the parse case this pipeline needs:

sealed interface TransferError {
record InvalidAmount(String raw) implements TransferError {}
record InsufficientFunds(Money shortfall) implements TransferError {}
record AccountFrozen(AccountId account) implements TransferError {}
}
Result<Receipt, TransferError> transfer(Account from, Account to, String rawAmount);

That signature is the detour sign. Every failure the operation expects is in the caller’s face, compiler-checked, before a line of the body is read. And the call site stops being a try/catch ceremony and becomes a switch over data:

String response = switch (transfer(from, to, rawAmount)) {
case Result.Ok<Receipt, TransferError> ok ->
"transferred: " + ok.value().id();
case Result.Err<Receipt, TransferError> err -> switch (err.error()) {
case TransferError.InvalidAmount(String raw) ->
"not an amount: " + raw;
case TransferError.InsufficientFunds(Money shortfall) ->
"you are short by " + shortfall;
case TransferError.AccountFrozen(AccountId account) ->
"account frozen: " + account;
};
};

Two properties of this switch do work that no catch arrangement can. It is exhaustive: both switches enumerate every case of a sealed type with no default, so when the business adds a CurrencyMismatch case next quarter, this code — and every call site like it — stops compiling until someone decides what to do. A new RuntimeException subclass, by contrast, changes nothing anywhere; the first notification is the incident. And the error is plain data: InsufficientFunds carries the shortfall because the error was designed to be acted on, not merely reported — and a test asserts on it as data (assertThat(result).isErr(), via the library’s AssertJ helpers), no assertThrows gymnastics, because errors that are values are values in tests too.


Failures compose without the pyramid

The objection writes itself: “so now every call returns a wrapper — won’t the happy path drown in unwrapping?” It would, if you unwrapped. You don’t; you chain. Give parseAmount the functional signature — Result<BigDecimal, TransferError>, the value-returning twin of the throwing version from earlier — and each step feeds the next:

Result<Receipt, TransferError> receipt =
parseAmount(rawAmount) // Result<BigDecimal, TransferError>
.flatMap(amount -> withdraw(from, amount)) // Result<Debit, TransferError>
.flatMap(debit -> deposit(to, debit)); // Result<Receipt, TransferError>

flatMap runs the next step only on Ok; at the first Err the chain short-circuits — the remaining steps never run, and that error value is what the caller receives. That is the entire railway pattern — the success track and the failure track, drawn in types — and it replaces the nested try/catch pyramid with a straight line that reads in execution order. One requirement makes the links click together: every step must speak the same error type, as these three do. When a step has its own vocabulary — a ParseError minted in another module, say — mapError translates it into the chain’s type at the seam; the error-channel post in the reading list is entirely about that move. And when the job is validating input rather than executing steps — where you want every problem reported, not the first — the sibling type Validated accumulates instead of short-circuiting. It is a deliberately different composition discipline, not a drop-in swap — it trades flatMap away and combines independent checks instead — but it exists, and it is reachable. With exceptions, first-failure-wins is the only behavior on offer.


The border with the throwing world

You do not get to rewrite the JDK, the drivers, or the HTTP clients — the ecosystem throws, and pretending otherwise produces a library-shaped fantasy. The functional answer is a border, not a crusade: Try captures the throw at the edge where it happens, and toResult names it on the way into your domain:

sealed interface CustomerLookupError {
record NotFound(CustomerId id) implements CustomerLookupError {}
record StoreUnavailable(Throwable cause) implements CustomerLookupError {}
}
Result<Customer, CustomerLookupError> byId(CustomerId id) {
Result<Option<Customer>, CustomerLookupError> fetched =
Try.of(() -> Option.ofNullable(repository.fetchCustomer(id))) // SQLException captured here
.toResult(CustomerLookupError.StoreUnavailable::new);
return fetched.flatMap(found -> found.toResult(new CustomerLookupError.NotFound(id)));
}

Two details in that snippet are load-bearing. First, the possible absence is wrapped in Option before it can reach Result: a Try will happily carry a null success, but the conversion inside toResult finishes at Result.ok, which rejects null outright — and a repository that returns null for a missing row is the most common lookup shape in Java. Wrapping first (in the capture as here, or in a map(Option::ofNullable) step — anywhere before the conversion) is what lets the last line name the absence NotFound instead of the border throwing a NullPointerException of its own. Second, Try.of captures Throwableeverything, including the OutOfMemoryError the next section will tell you not to handle, and interruptions whose flag deserves restoring (often arriving wrapped as another exception’s cause). The capture is indiscriminate by design; discriminating is your job, and it happens at the conversion. A border that must stay strict about disasters handles them in an explicit catch rather than burying the policy in a mapper — the shape the HTTP guide uses for exactly this reason. Conversion, not capture, is the scalpel.

Inside the border, failures are typed values; outside it, the throwing world carries on unoffended. The exception did not disappear — it got named, at the one place that knows what it means, which is the same boundary discipline that keeps a repository’s SQLException from surfacing in an HTTP handler.


What exceptions are still for

The honest limit, because “without exceptions” in the title means without exceptions as expected control flow, not without exceptions at all.

Bugs stay exceptions. A violated precondition — the null that must never be null, the state machine in a state it cannot be in — is not an expected failure; it is wrong code. Objects.requireNonNull and IllegalStateException are correct there, because the remedy is a fix, not a handler, and a loud crash close to the defect beats a typed value that lets wrong code keep executing. Unrecoverable infrastructure stays exceptions: nobody pattern-matches their way out of OutOfMemoryError. The working rule: Result for outcomes the business expects and the caller can act on; exceptions for defects and disasters. When the two blur — and at real boundaries they do — the longer treatment of when “just use exceptions” is actually right draws the line case by case.

What is left after the carve-outs is most of the failure handling a backend service does all day: the not-found, the invalid, the declined, the frozen, the over-limit. Those were never exceptional — they are the business, on its other track. Post the sign where the road breaks, in a shape every caller reads at a glance, and let traffic keep flowing.


Further reading


Found a bug or have a suggestion? Open an issue on GitHub.