EitherOrBoth: Success with Warnings
Keep a value's warnings beside it, and decide at the boundary what they mean.
| Models | A value, an error, or a value with warnings beside it |
| Replaces | A result record holding a value and a list of warnings |
| Effect Path | EitherOrBothPath |
| Instances | Functor, Applicative, Monad, Foldable, Traverse, Bifunctor |
| Choose it when | A success can carry problems that are not fatal; otherwise Validated, or compare every type in Choosing a Type |
- Return a value with warnings as a
Both, keeping a fatal problem as aLeft - Join independent checks and keep every warning, even when one check is fatal
- Predict what
flatMapdoes with the warnings of each case - Decide at the boundary what happens to a
Both's warnings - Keep the same steps on an
EitherOrBothPath
The code on this page is EitherOrBothBook.java: the page includes it, so the build compiles and runs it. EitherOrBothBookTest.java holds what the page says each method does.
A success with warnings
Either and Validated are exclusive: a result is a success or a failure, never both. Some outcomes are both. An order line imported from a wholesaler's feed may need correcting before it can be used: a lower-case SKU upper-cased, or a price with a fraction of a penny rounded. The line is usable, and the importer should still report what it changed. Java code usually returns a record of the value and a List of warnings, which no map or flatMap knows how to carry.
EitherOrBoth<L, R> is an inclusive-or: a Left, a Right, or Both at once. Success goes on the right and problems on the left, so a Both is a value with the warnings found while producing it. Cats calls the type Ior, and Haskell These.
The code on this page uses these imports, with LineItem from the order records the Optics chapter introduces:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import org.higherkindedj.example.book.optics.cast.LineItem;
import org.higherkindedj.hkt.either.Either;
import org.higherkindedj.hkt.eitherorboth.EitherOrBoth;
import org.higherkindedj.hkt.maybe.Maybe;
import org.higherkindedj.hkt.nonemptylist.NonEmptyList;
import org.higherkindedj.hkt.validated.Validated;
Each of these checks passes, fails, or passes with something to say. A warning goes in a NonEmptyList, since a Both always has at least one:
// A check can pass, fail, or pass with something to say
static EitherOrBoth<NonEmptyList<String>, String> sku(String sku) {
if (sku.isBlank()) {
return EitherOrBoth.left(NonEmptyList.single("sku is missing"));
}
String upper = sku.toUpperCase(Locale.ROOT);
return upper.equals(sku)
? EitherOrBoth.right(sku)
: EitherOrBoth.both(NonEmptyList.single("sku " + sku + " upper-cased"), upper);
}
static EitherOrBoth<NonEmptyList<String>, Integer> quantity(int quantity) {
return quantity > 0
? EitherOrBoth.right(quantity)
: EitherOrBoth.left(NonEmptyList.single("quantity " + quantity + " is not positive"));
}
static EitherOrBoth<NonEmptyList<String>, BigDecimal> price(BigDecimal price) {
if (price.scale() <= 2) {
return EitherOrBoth.right(price);
}
BigDecimal rounded = price.setScale(2, RoundingMode.HALF_UP);
return EitherOrBoth.both(
NonEmptyList.single("price " + price + " rounded to " + rounded), rounded);
}
Every warning in one result
EitherOrBoth.accumulate() joins independent checks, as Validated.accumulate() does, and keeps the value flowing past each warning. A single fatal check makes the whole result a Left, which holds the fatal problem beside every warning, in the order of the checks:
static EitherOrBoth<NonEmptyList<String>, LineItem> line(
String sku, int quantity, BigDecimal price) {
return EitherOrBoth.accumulate()
.and(sku(sku))
.and(quantity(quantity))
.and(price(price))
.apply(LineItem::new);
}
EitherOrBoth<NonEmptyList<String>, LineItem> clean = line("LAMP", 2, new BigDecimal("40.00"));
// Right(LineItem[sku=LAMP, quantity=2, price=40.00])
EitherOrBoth<NonEmptyList<String>, LineItem> corrected =
line("lamp", 2, new BigDecimal("40.005"));
// Both(NonEmptyList[sku lamp upper-cased, price 40.005 rounded to 40.01],
// LineItem[sku=LAMP, quantity=2, price=40.01])
EitherOrBoth<NonEmptyList<String>, LineItem> refused =
line("lamp", 0, new BigDecimal("40.005"));
// Left(NonEmptyList[sku lamp upper-cased, quantity 0 is not positive,
// price 40.005 rounded to 40.01])
A switch over the three cases reads the result, and the compiler checks that each is handled:
static String describe(EitherOrBoth<NonEmptyList<String>, LineItem> result) {
return switch (result) {
case EitherOrBoth.Right(LineItem line) -> "imported " + line.sku();
case EitherOrBoth.Both(NonEmptyList<String> warnings, LineItem line) ->
"imported " + line.sku() + " with " + warnings.size() + " warning(s)";
case EitherOrBoth.Left(NonEmptyList<String> problems) ->
"refused: " + String.join("; ", problems.toJavaList());
};
}
Accumulating Assembly covers the builder in full, including EitherOrBoth.fields(), which names the field each warning belongs to.
The flatMap contract
A step that needs the whole line can only follow the checks. flatMap(semigroup, step) takes a Semigroup, which joins two values, as well as the step, because a Both's warnings must be joined with any the step adds. accumulate() needs none, because it takes NonEmptyList warnings and joins them itself. REVIEW_LIMIT is 100.00, and a line whose total exceeds it should be checked by a person:
// This step needs the whole line, so it can only follow the checks
static EitherOrBoth<NonEmptyList<String>, LineItem> review(LineItem line) {
BigDecimal total = line.price().multiply(BigDecimal.valueOf(line.quantity()));
return total.compareTo(REVIEW_LIMIT) > 0
? EitherOrBoth.both(NonEmptyList.single("line total " + total + " flagged"), line)
: EitherOrBoth.right(line);
}
static EitherOrBoth<NonEmptyList<String>, LineItem> reviewedLine(
String sku, int quantity, BigDecimal price) {
return line(sku, quantity, price).flatMap(NonEmptyList.semigroup(), EitherOrBothBook::review);
}
EitherOrBoth<NonEmptyList<String>, LineItem> flagged =
reviewedLine("lamp", 3, new BigDecimal("40.005"));
// Both(NonEmptyList[sku lamp upper-cased, price 40.005 rounded to 40.01,
// line total 120.03 flagged], LineItem[sku=LAMP, quantity=3, price=40.01])
import review later step
Right ════●══════════════════●══════════════▶ Right(line)
╲ review adds a warning
Both ════●════════════════════●═══════●═════▶ Both(every warning, line)
╲ it gives a Left
Left ────●──────────────────────────────●───▶ Left(every warning)
a fatal check:
the review never runs
The diagram shows a Left as the only track that stops the chain, and a review that moves a Right onto the Both track. A Left is fatal, so flatMap never runs the step, and a Right gives whatever the step gives. For Both(w, r).flatMap(semigroup, f), the warnings w join whatever f(r) gives:
f(r) gives | flatMap gives |
|---|---|
Left(w2) | Left(w ⊕ w2) |
Right(r2) | Both(w, r2) |
Both(w2, r2) | Both(w ⊕ w2, r2) |
w ⊕ w2 is semigroup.combine(w, w2): with NonEmptyList.semigroup(), the warnings carried in come first, then the step's.
Converting at the boundary
Code outside the importer may take an Either, a Validated or a Maybe. The two Either conversions are named for what they do with a Both's warnings, so the choice is visible at the call. toValidated() and toMaybe() drop the warnings, so read them first with getLeft() if you need them:
Either<NonEmptyList<String>, LineItem> dropping = corrected.toEitherDroppingWarnings();
// Right(LineItem[sku=LAMP, quantity=2, price=40.01])
Either<NonEmptyList<String>, LineItem> failing = corrected.toEitherFailingOnWarnings();
// Left(NonEmptyList[sku lamp upper-cased, price 40.005 rounded to 40.01])
Validated<NonEmptyList<String>, LineItem> validated = corrected.toValidated();
// Valid(LineItem[sku=LAMP, quantity=2, price=40.01])
Maybe<LineItem> maybe = corrected.toMaybe();
// Just(LineItem[sku=LAMP, quantity=2, price=40.01])
Maybe<NonEmptyList<String>> warnings = corrected.getLeft();
// Just(NonEmptyList[sku lamp upper-cased, price 40.005 rounded to 40.01])
| 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), warnings dropped |
EitherOrBoth.fromEither and fromValidated turn an Either or a Validated into an EitherOrBoth, and never give a Both.
On an EitherOrBothPath
Application code can hold the same steps on an EitherOrBothPath. Path.eitherOrBoth takes a result and the semigroup that joins its warnings, via chains a step with the flatMap contract, and warnings() reads them. These are the extra imports:
import org.higherkindedj.hkt.effect.EitherOrBothPath;
import org.higherkindedj.hkt.effect.Path;
static EitherOrBothPath<NonEmptyList<String>, LineItem> reviewedLinePath(
String sku, int quantity, BigDecimal price) {
return Path.eitherOrBoth(line(sku, quantity, price), NonEmptyList.<String>semigroup())
.via(line -> Path.eitherOrBoth(review(line), NonEmptyList.<String>semigroup()));
}
EitherOrBothPath<NonEmptyList<String>, LineItem> onThePath =
reviewedLinePath("LAMP", 3, new BigDecimal("40.00"));
// EitherOrBothPath(Both(NonEmptyList[line total 120.00 flagged],
// LineItem[sku=LAMP, quantity=3, price=40.00]))
Maybe<NonEmptyList<String>> flaggedOnPath = onThePath.warnings();
// Just(NonEmptyList[line total 120.00 flagged])
Methods
| Method | What it does |
|---|---|
EitherOrBoth.left(l), right(r), both(l, r) | Build each case; none accepts null |
isLeft(), isRight(), isBoth() | Say which case it holds |
getLeft(), getRight() | Return a Maybe of that side, never throwing; a Both has both |
map(f) | Changes the value of a Right or a Both |
mapLeft(f) | Changes the warnings or error of a Left or a Both |
bimap(onLeft, onRight) | Changes whichever sides it holds |
flatMap(semigroup, f) | Runs a step on the value, joining warnings as the contract table shows |
zipWithAccum(other, semigroup, f) | Joins two results, keeping every warning from both, even across a Left |
fold(onLeft, onRight, onBoth) | Turns any case into one result |
EitherOrBoth.accumulate(), fields() | Join independent checks, keeping every warning |
A Left, a Right and a Both never hold null, as Values, never null says for every type.
Working with Kind
Generic code, written once for any container, takes EitherOrBoth through its Monad instance, from Instances.eitherOrBoth(semigroup). The semigroup is how the instance's flatMap joins warnings. The instance works over Kind<EitherOrBothKind.Witness<L>, R>, so you widen a result to pass it in, and narrow the result to read it. These are the extra imports:
import static org.higherkindedj.hkt.eitherorboth.EitherOrBothKindHelper.EITHER_OR_BOTH;
import org.higherkindedj.hkt.Kind;
import org.higherkindedj.hkt.Monad;
import org.higherkindedj.hkt.eitherorboth.EitherOrBothKind;
import org.higherkindedj.hkt.instances.Instances;
Monad<EitherOrBothKind.Witness<NonEmptyList<String>>> importing =
Instances.eitherOrBoth(NonEmptyList.semigroup());
Kind<EitherOrBothKind.Witness<NonEmptyList<String>>, LineItem> imported =
EITHER_OR_BOTH.widen(line("lamp", 3, new BigDecimal("40.00")));
Kind<EitherOrBothKind.Witness<NonEmptyList<String>>, LineItem> checked =
importing.flatMap(item -> EITHER_OR_BOTH.widen(review(item)), imported);
EitherOrBoth<NonEmptyList<String>, LineItem> bothWarnings = EITHER_OR_BOTH.narrow(checked);
// Both(NonEmptyList[sku lamp upper-cased, line total 120.00 flagged],
// LineItem[sku=LAMP, quantity=3, price=40.00])
The instance is a Monad, not a MonadError, so it has no raiseError or handleErrorWith. EitherOrBoth has instances of other type classes too, such as Traverse and Bifunctor, each an object of its own, and EitherOrBoth at a glance, at the head of the page, lists them.
EitherOrBoth's Monad instance obeys the monad laws, so its ap agrees with its flatMap. When the function side is a Left, ap gives that Left and drops the argument's warnings, where Validated's ap keeps both sides' errors. To keep every warning even across a Left, join with zipWithAccum, on the value or on an EitherOrBothPath.
When to use EitherOrBoth
| You need | Use |
|---|---|
| A success that can carry warnings beside it | EitherOrBoth |
| The same steps as a fluent chain in application code | EitherOrBothPath |
| Every error, and no value at all when any check fails | Validated |
| The first error only, with no partial result | Either |
Practise keeping a value flowing past its warnings, in Exercise 5 of Tutorial 12: Accumulating Assembly (6 exercises).
Previous: Try Next: NonEmptyList