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(stuff).right
v2 <- bar(stuff).right
v3 <- baz(stuff).right
}
yield something
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
}
There are comments.