While playing around with Dotty, I wanted an error type which could operate with accumulation or fail-fast semantics as needed. I attempted to implement this as a set of opaque type alias over Either
, and decided to see if I could add the less common behavior as a layer over the more common behavior and forward as much of the functionality down-layer as possible.
What I found is that it looks like opaque type aliases can’t nest. I’ve created a smaller example of the behavior here:
package testing
// This is the behavior that we'll attempt to forward, and
// end up unable to do so.
trait Semigroup[A] {
def combine(lhs: A, rhs: A): A
}
object Semigroup {
given [A: Semigroup](lhs: A) {
def combine(rhs: A): A = summon[Semigroup[A]].combine(lhs, rhs)
}
given Semigroup[Int] {
def combine (lhs: Int, rhs: Int): Int = lhs + rhs
}
}
type ErrorMsg = String
// This is the default behavior
opaque type Accumulate[A] = Either[ErrorMsg, A]
object Accumulate {
def[A](aa: Accumulate[A]) unwrap: Either[ErrorMsg, A] = aa
def present[A](a: A): Accumulate[A] = Right(a)
def missing[A](e: ErrorMsg): Accumulate[A] = Left(e)
def extract[A,B](ma: Accumulate[A], fe: ErrorMsg => B, fa: A => B): B = ma.fold(fe, fa)
given[A](given SA: Semigroup[A]): Semigroup[Accumulate[A]] {
import Semigroup.given
def combine(lhs: Accumulate[A], rhs: Accumulate[A]): Accumulate[A] = (lhs, rhs) match {
case (Right(l), Right(r)) => Right(l combine r)
case (Left(l), Left(r)) => Left(s"$l|$r")
case (l @ Left(_), _) => l
case (_, r @ Left(_)) => r
}
}
}
// This is the optional behavior I'd expect to be able to layer
// on because the compiler sees Accumulate and ShortCircuit as
// unrelated types
opaque type ShortCircuit[A] = Accumulate[A]
object ShortCircuit {
import Accumulate.unwrap
def present[A](a: A): ShortCircuit[A] = Accumulate.present(a)
def missing[A](e: ErrorMsg): ShortCircuit[A] = Accumulate.missing(e)
def extract[A,B](ma: ShortCircuit[A], fe: ErrorMsg => B, fa: A => B): B = Accumulate.extract(ma, fe, fa)
given[A](given SA: Semigroup[A]): Semigroup[ShortCircuit[A]] {
import Semigroup.given
def combine(lhs: ShortCircuit[A], rhs: ShortCircuit[A]): ShortCircuit[A] = {
println(s"Combining $lhs & $rhs")
(lhs.unwrap, rhs.unwrap) match {
case (Right(l), Right(r) ) => Accumulate.present(l combine r)
case (l @ Left(_), _ ) => l
case (_, r ) => r
}
}
}
}
@main def main(): Unit = {
// Tangentally: it seems like the companion objects of opaque types
// are not part of the implicit scope. Is this intentional?
//import Accumulate.given
import ShortCircuit.given
import Semigroup.given
val formatI: Int => String = i => s"Value: $i"
val formatE: ErrorMsg => String = e => s"Error: $e"
def outputA(a: Accumulate[Int]): Unit = {
println(Accumulate.extract(a, formatE, formatI))
}
def outputS(s: ShortCircuit[Int]): Unit = {
println(ShortCircuit.extract(s, formatE, formatI))
}
val aI: Accumulate[Int] = Accumulate.present(1)
val aE: Accumulate[Int] = Accumulate.missing[Int]("Oh no!")
val sI: ShortCircuit[Int] = ShortCircuit.present(1)
val sE: ShortCircuit[Int] = ShortCircuit.missing[Int]("Oh no!")
println("==== Accumulating ====")
outputA(aI)
outputA(aE)
outputA(aI combine aI)
outputA(aI combine aE)
outputA(aE combine aI)
outputA(aE combine aE)
println("==== Short Circuiting ====")
outputS(sI)
outputS(sE)
outputS(sI combine sI)
outputS(sI combine sE)
outputS(sE combine sI)
outputS(sE combine sE)
}
Expected output:
==== Accumulating ====
Value: 1
Error: Oh no!
Value: 2
Error: Oh no!
Error: Oh no!
Error: Oh no!|Oh no!
==== Short Circuiting ====
Value: 1
Error: Oh no!
Combining Right(1) & Right(1)
Value: 2
Combining Right(1) & Left(Oh no!)
Error: Oh no!
Combining Left(Oh no!) & Right(1)
Error: Oh no!
Combining Left(Oh no!) & Left(Oh no!)
Error: Oh no!
The actual output varies:
- If the import for
Accumulate.given
is present, they all exhibit accumulating behavior. - If the import for
ShortCircuit.given
is present, they all exhibit short-circuit behavior. - If both are present, the compiler complains about ambiguous implicits.
- If neither are present, the compiler complains about missing implicits.
Apparently, opaque type aliases don’t have implicit scope?
As the docs are quiet about what happens when you try to nest opaque types, I wanted to raise two questions:
- Is this the intended behavior?
- If so, should it be?