EitherOrBoth:
Success That Can Also Carry Warnings
- Why neither
EithernorValidatedcan model "success with warnings", and howEitherOrBothfills the gap - The three cases (
Left,Right,Both) and the total accessors (getLeft/getRight) that never throw - The accumulating
flatMapcontract:Leftshort-circuits,Bothcarries its warnings forward - Why the monadic
apshort-circuits (and is not the same asValidated's accumulatingap) - Converting to
Either/Validated/Maybeat the boundary, deciding what happens to warnings - That
EitherOrBothis known elsewhere asIor(Cats) orThese(Haskell)
The Problem: A Success With Warnings Has No Home
Higher-Kinded-J models two failure shapes, and both are exclusive:
Either<L, R>isLeft(e)orRight(a): a result is a failure or a success, never both.Validated<E, A>accumulates errors, but is still exclusive:Invalidcarries errors and no value;Validcarries a value and no errors.
Neither models a genuinely common outcome: a successful value that also carries accumulated, non-fatal problems. Config that parses but reports deprecations; a document that renders but logs recoverable issues; an import that produces records and a list of rows it had to skip. The usual workarounds (Either<E, Pair<List<Warning>, A>>, a bespoke result record, or simply dropping the warnings) do not compose with the railway combinators, so the warning channel becomes manual plumbing.
EitherOrBoth<L, R> is the principled type. It is an inclusive-or: a Left, a Right, or Both at once.
EitherOrBoth<NonEmptyList<Warning>, Config> result =
parseConfig(raw) // Right(cfg), Both(warnings, cfg), or Left(fatal)
.flatMap(NonEmptyList.semigroup(), cfg -> validateConfig(cfg)); // warnings accumulate
By convention it is right-biased: success on the right, problems on the left, so map / flatMap feel identical to the rest of the railway. A Both is therefore "a successful value and the warnings gathered while producing it".
EitherOrBoth is called Ior (inclusive-or) in Cats and These in Haskell. This library uses the
descriptive name; the aliases are noted only so the concept is recognisable.
The Three Cases
EitherOrBoth is a sealed interface over three immutable records, so values are fully
switch-matchable:
String describe(EitherOrBoth<NonEmptyList<Warning>, Config> r) {
return switch (r) {
case EitherOrBoth.Left<NonEmptyList<Warning>, Config>(var w) -> "failed: " + w;
case EitherOrBoth.Right<NonEmptyList<Warning>, Config>(var cfg) -> "ok: " + cfg;
case EitherOrBoth.Both<NonEmptyList<Warning>, Config>(var w, var c) -> "ok with warnings: " + c;
};
}
Create values with left / right / both:
EitherOrBoth<String, Integer> left = EitherOrBoth.left("fatal");
EitherOrBoth<String, Integer> right = EitherOrBoth.right(42);
EitherOrBoth<String, Integer> both = EitherOrBoth.both("deprecated key", 42);
Values are never null: the components are validated at construction, which is what keeps the
total accessors honest.
Total Accessors (No Throwing Getters)
Unlike Either's throwing getLeft / getRight, EitherOrBoth exposes total accessors that
return a Maybe:
| Accessor | Left(l) | Right(r) | Both(l, r) |
|---|---|---|---|
getLeft() | Just(l) | Nothing | Just(l) |
getRight() | Nothing | Just(r) | Just(r) |
isLeft() / isRight() / isBoth() | true / – / – | – / true / – | – / – / true |
fold collapses all three cases at once:
String s = both.fold(
warnings -> "failed: " + warnings,
value -> "ok: " + value,
(w, v) -> "ok (" + v + ") with " + w);
The flatMap Contract (the subtle part)
flatMap is right-biased and accumulates the left channel using a Semigroup<L> (the same mechanism
Validated uses). The contract is: Left is fatal and short-circuits; Both carries its warnings
forward and accumulates them.
Right ═══●═══════════════●═══▶ value
Both ═══●═══════════════●═══▶ value + warnings
map flatMap f (w ⊕ w' accumulate, left to right)
╲ ╲ a flatMap step may return Left
╲ ╲
Left ────●───────────────●───▶ short-circuit, f never runs
EitherOrBoth<L, R2> flatMap(
Semigroup<L> semigroup,
Function<? super R, ? extends EitherOrBoth<L, ? extends R2>> mapper);
For Both(l, r).flatMap(⊕, f):
f(r) returns | result |
|---|---|
Left(l2) | Left(l ⊕ l2) |
Right(r2) | Both(l, r2) |
Both(l2, r2) | Both(l ⊕ l2, r2) |
Left(l) returns Left(l) without running f; Right(r) returns f(r) unchanged. Accumulation is
left-to-right and needs only an associative Semigroup.
EitherOrBoth is a lawful Monad, so its ap is consistent with flatMap: when the
function side is a Left, ap short-circuits and the argument's left is dropped. This is different
from Validated.ap, which collects errors from both sides even across a failure.
If you want the fully-parallel "collect every warning, even across a fatal Left" behaviour, use
EitherOrBothPath's accumulating combinators (zipWithAccum,
andAlso). The monadic operations here are for sequential, short-circuiting composition.
The default warning channel is NonEmptyList: a Both always has at least
one warning, so a non-empty list is the exact fit, and NonEmptyList.semigroup() supplies the
accumulation.
Conversions
The interesting edge is what happens to a Both's warnings at the boundary. The conversions are
named so the decision is explicit:
| Conversion | Left(l) | Right(r) | Both(l, r) |
|---|---|---|---|
toEitherDroppingWarnings() | Left(l) | Right(r) | Right(r) (warnings dropped) |
toEitherFailingOnWarnings() | Left(l) | Right(r) | Left(l) (warnings fatal) |
toValidated() | Invalid(l) | Valid(r) | Valid(r) (warnings dropped) |
toMaybe() | Nothing | Just(r) | Just(r) |
fromEither and fromValidated lift the exclusive types in (the Both case is unreachable from
them).
When To Use It
| Type | Use when |
|---|---|
Either<L, R> | A clean, exclusive choice: success or failure, no partial results |
Validated<E, A> | Pure accumulation: collect all errors, with no partial value on failure |
EitherOrBoth<L, R> | Success that may also carry non-fatal warnings (or a genuinely partial result) |
EitherOrBoth is complementary to the other two, not a replacement: reach for it precisely when a
result can be both a value and a set of problems.
EitherOrBothis an inclusive-or:Left,Right, orBothat once: the type for "success with warnings".- Right-biased with total accessors:
map/flatMapoperate on the right;getLeft/getRightreturnMaybeand never throw. flatMapaccumulates:Leftshort-circuits;Bothcarries warnings forward, combining them via aSemigroup(defaultNonEmptyList.semigroup()).- Monadic
apshort-circuits, unlikeValidated's accumulatingap; useEitherOrBothPathfor full parallel accumulation. - Explicit conversions force a decision about warnings at the boundary.
- EitherOrBothPath: the fluent railway wrapper, with both short-circuit and accumulating composition
- Either: the exclusive success-or-failure sibling
- Validated: pure error accumulation with no partial value
- NonEmptyList: the non-empty warning channel a
Bothpairs with - Semigroups and Monoids: how the left channel accumulates