The transformer in the photo does one job: electricity arrives at a voltage that suits transmission, and leaves at a voltage that suits the street. Nobody calls that a hack. The grid depends on energy changing shape at every boundary it crosses, and the device that does it is ordinary, deliberate infrastructure.
Failures in a typed pipeline need the same infrastructure. A
Result<Value, Error> carries two channels, and most of this
blog’s attention has gone to the first one: map and flatMap transform the value and
pass errors through untouched. This post is about the second channel — the one carrying
the failure — and the small family of operations that transform it: what they are, the
boundaries where you must use them, and the two ways teams reliably get them wrong
(too early, and into strings).
The tools, in one place
Each core type has its error-channel move, and they rhyme:
// Result: change the error type, value channel untouchedResult<Config, ParseError> parsed = parse(raw);Result<Config, StartupError> ready = parsed.mapError(StartupError.BadConfig::new);
// Try: the error channel is always Throwable — mapFailure reshapes within itTry<byte[]> read = Try.of(() -> Files.readAllBytes(path)) .mapFailure(e -> new ConfigException("config unreadable: " + path, e));
// Try -> Result: leave the exception world, naming the error as you crossResult<byte[], StartupError> loaded = Try.of(() -> Files.readAllBytes(path)) .toResult(StartupError.Unreadable::new);
// Validated and Either have the same lever: mapError / mapLeftThree details earn their keep in real code. Try.mapFailure maps Throwable to
Throwable — Try’s failure channel is fixed by design, so the typed exit is
toResult(errorMapper), which is where an exception becomes a domain error. Result’s
recoverWith can change the error type while attempting the rescue
(Result<V, E2> out), so a fallback and a translation can be one step. And Guard
composes the same idea for validation messages: mapMessages(msg -> "user: " + msg)
rewrites every accumulated message in one pass — the
guard guide covers it alongside the field-labeling contramap.
One inference wrinkle worth knowing before it bites: in the middle of a flatMap chain,
Java sometimes cannot infer the target error type of a toResult call — the fix is an
explicit witness, .<StartupError>toResult(...), and it is needed only there, not on
plain assignments where the left-hand side pins the type.
When mapping the error channel is the right move
At layer boundaries — this one is not optional. The repository speaks
SqlException; the domain speaks OrderError; the HTTP adapter speaks status codes. An
error type that crosses a layer uncontained is a leak: the moment handleOrder pattern
matches on SqlException, your controller depends on your persistence choice, and
swapping Postgres for a REST call becomes a controller change. The
good-error-type post argues for carving
errors by caller action; mapError at the boundary is what enforces the carving —
each layer’s errors minted in that layer’s vocabulary, translated exactly at the seam,
like voltage stepped at the substation and never inside the living room.
Result<Order, OrderError> find(OrderId id) { return repository.findRow(id) // Result<Row, RepoError> .mapError(this::asDomainError) // RepoError -> OrderError, at the seam .flatMap(Order::fromRow);}To make one pipeline out of two vocabularies. flatMap requires the error types to
agree — a Result<A, ParseError> cannot chain into a function returning
Result<B, ValidationError>. That is not the compiler being difficult; it is the
compiler asking what a combined failure means. The answer is a common error type
(usually a sealed hierarchy with one case
per source) and a mapError on each branch lifting into it — the
railway post shows the track
merging at length.
To attach context the raw failure lacks. A NumberFormatException knows the string
that failed; it does not know it was parsing line 41,772 of a partner file. The
enrichment move — mapFailure(e -> new ImportException("line " + lineNumber, e)) —
adds what only the call site knows, keeping the original as the cause. Context grows
as the failure travels outward; nothing is discarded.
To stop carrying Throwable around. Try is the right container at the
throwing boundary, and the wrong one to build an API on — Throwable tells callers
nothing about which failures are theirs to handle. toResult(errorMapper) at the edge
of the throwing code converts “something threw” into “one of these named things
happened,” which is the difference between a
catch-all and a contract.
The two classic mistakes
Mapping too early. The reflex is to translate a failure the instant it appears —
inside the helper, inside the retry loop. But typed recovery matches the top-level
failure only: recover(IOException.class, ...) tests the Failure’s own cause with an
instanceof, so once an eager mapFailure has wrapped the SocketTimeoutException in
a ConfigException, the match fails even though the original sits right there in the
cause chain. Preserving the cause (as the enrichment move above does) keeps the
evidence for logs; it does not restore the dispatch — retry logic that asks “was
this transient?” must either run before the wrapping or dig through getCause() by
hand. The working rule: transform at boundaries, not at birth. Within a layer, keep the failure in
that layer’s native type — precision is capability, and every early translation spends
it.
Mapping into strings. mapError(Throwable::getMessage) type-checks, reads tidy,
and destroys the channel: strings cannot be pattern matched, carry no cause, and turn
every downstream decision into substring inspection — and since getMessage() is
nullable (many JDK exceptions carry no message), the tidy one-liner smuggles the
null problem into the error channel too. A string is the last shape a
failure should take — at the log line, at the response body — after every decision has
been made on typed cases. If the error type you are mapping into is String, the
pipeline has decided to stop deciding. (The disciplined exception is validation, where
Validated accumulates human-facing
messages on purpose — those strings are the product, not the control flow.)
The symmetry with the value channel is the takeaway worth keeping. Nobody hesitates to
map a value through three representations on its way to a response; the error channel
deserves the same deliberate staging — raw at birth, domain-shaped at the seam,
human-shaped at the edge. One channel carries what went right, the other what went
wrong, and both are data in transit: step the voltage where the wires change owners,
and the failure arrives at each consumer in the shape that consumer can act on.
Further reading
- Result — the library guide —
mapError,recover,recoverWith, and the rest of the error-channel surface. - Try — the library guide —
mapFailure, typedrecover, andtoResultat the throwing boundary. - Designing a Good Error Type: Sealed Hierarchies Callers Can Act On — what to map into: cases carved by caller action.
- Railway-Oriented Programming in Java (Without Frameworks)
— the pipeline shape whose tracks
mapErrormerges. - Validated: Accumulating Errors in a Functional Way — the error channel that accumulates instead of short-circuiting.
- Why ‘Just Use Exceptions’ Persists, and When It Is Actually Right
— the boundary where
TryandtoResultmeet the throwing world.
Found a bug or have a suggestion? Open an issue on GitHub.
