How to implement an ADT for a container with one or many values in Scala

193 Views Asked by At

At the end of the day, here is what I want to achieve :

  val onePath: One = new Log(OneLocation("root"), "foo/bar").getPath()
  val manyPath: Many = new Log(ManyLocation(List("base1", "base2")), "foo/bar").getPath()

In order to achieve that, it seems that an ADT representing one or many value(s) is required.

Here is my implementation. Is there another/better/simpler way to implement it (I've used path dependent type and F-bounded types). is there a library that already implements it (the use case seems prety current).

  sealed trait OneOrMany[T <: OneOrMany[T]] {
    def map(f: String => String) : T
  }
  final case class One(a: String) extends OneOrMany[One] {
    override def map(f: String => String): One = One(f(a))
  }
  final case class Many(a: List[String]) extends OneOrMany[Many] {
    override def map(f: String => String): Many = Many(a.map(f))
  }

  sealed trait Location {
    type T <: OneOrMany[T]
    def value: T
  }

  final case class OneLocation(bucket: String) extends Location {
    override type T = One
    override val value = One(bucket)
  }
  final case class ManyLocation(buckets: List[String]) extends Location {
    override type T = Many
    override val value = Many(buckets)
  }

  class Log[L <: Location](location: L, path: String) {
    def getPath(): L#T = location.value.map(b => s"fs://$b/$path")
  }
3

There are 3 best solutions below

6
Luis Miguel Mejía Suárez On BEST ANSWER

I am not sure if you actually need all that, why not just something like this?

@annotation.implicitNotFound(msg = "${T} is not a valid Location type.")
sealed trait Location[T] {
  def getPath(location: T, path: String): T
}

object Location {
  final def apply[T](implicit location: Location[T]): Location[T] = location

  implicit final val StringLocation: Location[String] =
    new Location[String] {
      override final def getPath(bucket: String, path: String): String =
        s"fs://${bucket}/$path"
    }
  
  implicit final val StringListLocation: Location[List[String]] =
    new Location[List[String]] {
      override final def getPath(buckets: List[String], path: String): List[String] =
        buckets.map(bucket => s"fs://${bucket}/$path")
    }
}

final class Log[L : Location](location: L, path: String) {
  def getPath(): L =
    Location[L].getPath(location, path)
}

Which works like this:

new Log(location = "root", "foo/bar").getPath()
// val res: String = fs://root/foo/bar

new Log(location = List("base1", "base2"), "foo/bar").getPath()
// val res: List[String] = List(fs://base1/foo/bar, fs://base2/foo/bar)

new Log(location = 10, "foo/bar").getPath()
// Compile time error: Int is not a valid Location type.

If you really, really, really want to have all those classes you can just do this:

sealed trait OneOrMany extends Product with Serializable
final case class One(path: String) extends OneOrMany
final case class Many(paths: List[String]) extends OneOrMany

sealed trait Location extends Product with Serializable {
  type T <: OneOrMany
}

final case class OneLocation(bucket: String) extends Location {
  override final type T = One
}

final case class ManyLocations(buckets: List[String]) extends Location {
  override final type T = Many
}

@annotation.implicitNotFound(msg = "Not found a Path for Path {L}")
sealed trait Path[L <: Location] {
  def getPath(location: L, path: String): L#T
}

object Path {
  implicit final val OneLocationPath: Path[OneLocation] =
    new Path[OneLocation] {
      override final def getPath(location: OneLocation, path: String): One =
        One(path = s"fs://${location.bucket}/$path")
    }
  
  implicit final val ManyLocationsPath: Path[ManyLocations] =
    new Path[ManyLocations] {
      override final def getPath(location: ManyLocations, path: String): Many =
        Many(paths = location.buckets.map(bucket => s"fs://${bucket}/$path"))
    }
}

final class Log[L <: Location](location: L, path: String) {
  def getPath()(implicit ev: Path[L]): L#T =
    ev.getPath(location, path)
}

Which works like you want:

val onePath: One = new Log(OneLocation("root"), "foo/bar").getPath()
// val onePath: One = One(fs://root/foo/bar)

val manyPath: Many = new Log(ManyLocations(List("base1", "base2")), "foo/bar").getPath()
// val manyPath: Many = Many(List(fs://base1/foo/bar, fs://base2/foo/bar)
1
Mateusz Kubuszok On

For me removal of dependent path works quite well:

sealed trait OneOrMany[T] { self: T =>
  def map(f: String => String) : T
}
final case class One(a: String) extends OneOrMany[One] {
  override def map(f: String => String): One = One(f(a))
}
final case class Many(a: List[String]) extends OneOrMany[Many] {
  override def map(f: String => String): Many = Many(a.map(f))
}
  
sealed trait Location[+T] {
  def value: T
}
final case class OneLocation(bucket: String) extends Location[One] {
  override val value = One(bucket)
}
final case class ManyLocation(buckets: List[String]) extends Location[Many] {
  override val value = Many(buckets)
}

// the only place we require OneOrMany[T]
// to provide .map(String => String): T method
class Log[T](location: Location[OneOrMany[T]], path: String) {
  def getPath(): T = location.value.map(b => s"fs://$b/$path")
}
@ val onePath: One = new Log(OneLocation("root"), "foo/bar").getPath()
onePath: One = One("fs://root/foo/bar")

@ val manyPath: Many = new Log(ManyLocation(List("base1", "base2")), "foo/bar").getPath()
manyPath: Many = Many(List("fs://base1/foo/bar", "fs://base2/foo/bar"))
0
Dmytro Mitin On

I would replace F-bounded polymorphism with a type class (Mapper).

OneOrMany is implemetation detail of Location but in location.value.map... maybe we slightly break encapsulation (Log knows not only about Location but also about OneOrMany and that it can map in OneOrMany).

I would avoid type projections (L#T) unless they're really necessary (or you use them intentionally).

Here is kind of type-level implementation (although maybe over-engineered)

// libraryDependencies += "com.github.dmytromitin" %% "auxify-macros" % "0.8"
import com.github.dmytromitin.auxify.macros.{aux, instance, syntax}
import Mapper.syntax._
import LocMapper.syntax._

sealed trait OneOrMany
final case class One(a: String) extends OneOrMany
final case class Many(a: List[String]) extends OneOrMany

@syntax
trait Mapper[T <: OneOrMany] {
  def map(t: T, f: String => String) : T
}
object Mapper {
  implicit val one: Mapper[One] = (t, f) => One(f(t.a))
  implicit val many: Mapper[Many] = (t, f) => Many(t.a.map(f))
}

@aux
sealed trait Location {
  protected type T <: OneOrMany
  val value: T
}
final case class OneLocation(bucket: String) extends Location {
  override type T = One
  override val value = One(bucket)
}
final case class ManyLocation(buckets: List[String]) extends Location {
  override type T = Many
  override val value = Many(buckets)
}

@aux @instance
trait ToLocation[T <: OneOrMany] {
  type Out <: Location.Aux[T]
  def apply(t: T): Out
}
object ToLocation {
  implicit val one: Aux[One, OneLocation] = instance(t => OneLocation(t.a))
  implicit val many: Aux[Many, ManyLocation] = instance(t => ManyLocation(t.a))
}

@syntax
trait LocMapper[L <: Location] {
  def map(l: L, f: String => String): L
}
object LocMapper {
  implicit def mkLocMapper[T <: OneOrMany, L <: Location.Aux[T]](implicit
    ev: L <:< Location.Aux[T],
    m: Mapper[T],
    toLoc: ToLocation.Aux[T, L]
  ): LocMapper[L] = (l, f) => toLoc(l.value.map(f))
}

class Log[L <: Location : LocMapper](location: L, path: String) {
  def getPath(): L = location.map(b => s"fs://$b/$path")
}

val onePath: OneLocation = new Log(OneLocation("root"), "foo/bar").getPath()
// OneLocation(fs://root/foo/bar)
val manyPath: ManyLocation = new Log(ManyLocation(List("base1", "base2")), "foo/bar").getPath()
// ManyLocation(List(fs://base1/foo/bar, fs://base2/foo/bar))