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 untouched
Result<Config, ParseError> parsed = parse(raw);
Result<Config, StartupError> ready = parsed.mapError(StartupError.BadConfig::new);
// Try: the error channel is always Throwable — mapFailure reshapes within it
Try<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 cross
Result<byte[], StartupError> loaded =
Try.of(() -> Files.readAllBytes(path))
.toResult(StartupError.Unreadable::new);
// Validated and Either have the same lever: mapError / mapLeft

Three details earn their keep in real code. Try.mapFailure maps Throwable to ThrowableTry’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


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