I’ve been using Scala’s Either
type a bit recently, and found a couple of minor annoyances with it. So as a learning exercise I tried to make a variation of Either
, and this is what I came up with. I also wanted to experiment with syntax a little. See what you think.
Either
is a disjunctive type with two possible values: a left and a right. It places no meaning at all on left or right, but 99% of its uses in practice involve putting normal, expected values in right, and errors or other “exceptional” values in left. This means that almost all the time you end up getting the RightProjection
, e.g.:
for { v1 <- foo(…).right v2 <- bar(…).right v3 <- baz(…).right } yield …
Whereas I’ve never used .left
even once.
The second minor issue is that Either has some limitations with patterns in for
expressions, because of how it is forced to implement the filter
method. See this Stack Overflow question for the details.
So I created a type that is explicitly designed to handle the possibility of “exception” conditions. Because the word “exception” already has a well known meaning on the JVM, I called this type Anomaly instead. Actually, I ended up calling the type |:
because I thought it worked nicely syntactically. Here’s a comparison with Either
:
val e1: Either[String, Int] = Right(30) val e2: Either[String, (Int, Int)] = Right((10, 2)) val e3 = for { i <- e1.right j <- e2.right } yield i + j._1 + j._2 val e4: Either[String, Int] = Left("error")
vs “Anomaly”:
val a1: String |: Int = Expected(30) val a2: String |: (Int, Int) = Expected((10, 2)) val a3 = for { i <- a1 (j, k) <- a2 } yield i + j + k val a4: String |: Int = Anomaly("error")
The |:
operator is right-associative, which helps for the occasional cases where you have an Anomaly within an Anomaly, eg. String |: String |: Int
is equivalent to Either[String, Either[String, Int]]
. Of course, depending on your taste you could reverse the order of type arguments, using an operator like :|
, or use a name instead of an operator.
There’s a cost to making the for
expression pattern matching work like this, however. In addition to the Anomaly
and Expected
cases, I had to introduce a NoValue
case object, which is what is returned when a filter does not match. This is annoying, because now folding or pattern matching must account for three cases instead of two. And unless you have explicit filters or patterns matching particular values, the NoValue
outcome won’t come up, so it’s a pain to have to check for it. What I ended up doing was providing an alternate two-parameter fold method that calls error
in case of a NoValue
. You just have to be careful about only calling it when you know it’s safe.
All-in-all I’m still wondering whether this experiment is worthwhile, but here’s the code in case anyone is interested. There are other utility methods that could be added:
sealed abstract class |:[+A, +E] { def anomaly: A def expected: E def isExpected: Boolean = false def isAnomaly: Boolean = false def isNoValue: Boolean = false def map[X](f: E => X): A |: X def flatMap[X, AA >: A](f: E => AA |: X): AA |: X def withFilter(f: E => Boolean): A |: E = this final def filter(f: E => Boolean): A |: E = withFilter(f) def foreach(f: E => Unit): Unit = {} final def fold[X](fExpected: E => X, fAnomaly: A => X, fNoValue: => X): X = this match { case Anomaly(a) => fAnomaly(a) case Expected(e) => fExpected(e) case NoValue => fNoValue } final def fold[X](fExpected: E => X, fAnomaly: A => X): X = this match { case Anomaly(a) => fAnomaly(a) case Expected(e) => fExpected(e) case NoValue => error("Attempt to fold NoValue case") } final def join[X, AA >: A](implicit ev: E <:< (AA |: X)): AA |: X = this match { case Anomaly(a) => Anomaly(a) case Expected(e) => e case NoValue => NoValue } } final case class Expected[+A, +E](expected: E) extends (A |: E) { override def isExpected = true def anomaly: A = Predef.error("Not an anomaly") override def withFilter(f: E => Boolean) = if (f(expected)) this else NoValue def flatMap[X, AA >: A](f: E => AA |: X) = f(expected) def map[X](f: E => X) = Expected[A, X](f(expected)) override def foreach(f: E => Unit) { f(expected) } } object Expected { def cond[A, E](test: Boolean, expected: => E, anomaly: => A): A |: E = if (test) Expected(expected) else Anomaly(anomaly) } final case class Anomaly[+A, +E](anomaly: A) extends (A |: E) { override def isAnomaly = true def expected: E = Predef.error("Not an expected value") def flatMap[X, AA >: A](f: E => AA |: X) = Anomaly[AA, X](anomaly) def map[X](f: E => X) = Anomaly[A, X](anomaly) } object Anomaly { def cond[A, E](test: Boolean, anomaly: => A, expected: => E): A |: E = if (test) Anomaly(anomaly) else Expected(expected) } case object NoValue extends (Nothing |: Nothing) { override def isNoValue = true def anomaly = Predef.error("Not an anomaly") def expected = Predef.error("No value") def flatMap[X, AA >: Nothing](f: Nothing => AA |: X) = this def map[X](f: Nothing => X) = this }