EitherOrBoth: Success with Warnings

Keep a value's warnings beside it, and decide at the boundary what they mean.

EitherOrBoth at a glance

ModelsA value, an error, or a value with warnings beside it
ReplacesA result record holding a value and a list of warnings
Effect PathEitherOrBothPath
InstancesFunctor, Applicative, Monad, Foldable, Traverse, Bifunctor
Choose it whenA success can carry problems that are not fatal; otherwise Validated, or compare every type in Choosing a Type

What You'll Learn

  • Return a value with warnings as a Both, keeping a fatal problem as a Left
  • Join independent checks and keep every warning, even when one check is fatal
  • Predict what flatMap does with the warnings of each case
  • Decide at the boundary what happens to a Both's warnings
  • Keep the same steps on an EitherOrBothPath

See Example Code

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])

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) givesflatMap 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])
ConversionLeft(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()NothingJust(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

MethodWhat 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.

The instance's ap stops at a Left function

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 needUse
A success that can carry warnings beside itEitherOrBoth
The same steps as a fluent chain in application codeEitherOrBothPath
Every error, and no value at all when any check failsValidated
The first error only, with no partial resultEither

Hands-On Learning

Practise keeping a value flowing past its warnings, in Exercise 5 of Tutorial 12: Accumulating Assembly (6 exercises).


Previous: Try Next: NonEmptyList