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 Throwable — everything, 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
- Result — the library guide — the full API:
ok,err,flatMap,recover, and the collection helpers. - Try — the library guide — capturing the throwing world
and crossing back out with
toResult. - Designing a Good Error Type: Sealed Hierarchies Callers Can Act On
— what to put on the
Errtrack: cases carved by caller action. - Railway-Oriented Programming in Java (Without Frameworks)
— the two-track composition this post’s
flatMapchain sketches. - Validated: Accumulating Errors in a Functional Way — the sibling that reports every failure instead of the first one.
- Mapping the Error Channel: When and How to Transform Failures
—
mapErrorand friends: what happens to a typed failure as it crosses layer boundaries. - Testing in Functional Programming: Why It Is Often Simpler — asserting on errors that are plain data, with the AssertJ helpers.
- Why ‘Just Use Exceptions’ Persists, and When It Is Actually Right — the counterargument, taken seriously, with the cases where it wins.
Found a bug or have a suggestion? Open an issue on GitHub.
