scalafunctional-programmingscala-catssemigroup

Semigroup typeclass (Either) with slightly altered combine


Using cats.Semigroup one can write this:

import cats.Semigroup
import cats.implicits._

val l1: String Either Int = Left("error")
val r1: String Either Int = Right(1)
val r2: String Either Int = Right(2)

l1 |+| r1 // Left("error")
r1 |+| r2 // Right(3)

I would like to have an equally idiomatic operator (combine-like) that works like this:

E.g.:

Right(1) |+| Right(2) // Right(3) 
Right(1) |+| Left("2") // Right(1) 
Left("1") |+| Left("2") // Left("12") // in my particular case the wrapped value here does not really matter (could also be e.g. Left("1") or Left("2")), but I guess Left("12") would be the must logical result

Is there something like this already defined in e.g. cats on Either?


Solution

  • There are a bunch of lawful semigroup instances for Either, and which of them should be included in Cats was a matter of some debate. Cats, Scalaz, and Haskell all make different choices in this respect, and the instance you're describing (flipped but with both lefts and right combining) is different from all three of those, it doesn't have a specific name that I'm aware of, and it isn't provided under any name or in any form by Cats.

    That's of course not a problem in itself, since as we'll see below it's pretty easy to verify that this instance is lawful, but there is one potential issue you should be aware of. You don't really explain your intended semantics, but if you ever want to promote this to a Monoid, the fact that you pick the Right when you have both a Left and a Right means that your zero will have to be Left. This might be kind of weird if you're thinking of rights as successes and lefts as errors that are safe to ignore when combining values.

    You're asking about Semigroup, though, not Monoid, so let's just ignore that for now and show that this thing is lawful. First for the definition:

    import cats.kernel.Semigroup
    
    implicit def eitherSemigroup[A, B](implicit
      A: Semigroup[A],
      B: Semigroup[B]
    ): Semigroup[Either[A, B]] = Semigroup.instance {
      case (Right(x), Right(y)) => Right(B.combine(x, y))
      case (r @ Right(_), Left(_)) => r
      case (Left(_), r @ Right(_)) => r
      case (Left(x), Left(y)) => Left(A.combine(x, y))
    }
    

    And then the checking part:

    import cats.instances.int._
    import cats.instances.string._
    import cats.kernel.instances.either.catsStdEqForEither
    import cats.kernel.laws.discipline.SemigroupTests
    import org.scalacheck.Test.Parameters
    
    SemigroupTests(eitherSemigroup[String, Int]).semigroup.all.check(Parameters.default)
    

    And yeah, it's fine:

    + semigroup.associative: OK, passed 100 tests.
    + semigroup.combineAllOption: OK, passed 100 tests.
    + semigroup.repeat1: OK, passed 100 tests.
    + semigroup.repeat2: OK, passed 100 tests.
    

    Personally if I wanted something like this I'd probably use a wrapper to avoid confusing future readers of my code (including myself), but given that nobody really knows what the semigroup of Either should do, I don't think using a custom instance is as big of a problem as it is for most other types from the standard library.