Search code examples
scalascala-catszio

How to implement fluent interface for scala subtypes?


I can implement nested classes with fluent interfaces in the following fashion:

class Animal(name: String, props: Map[String, Any]) {
  def properties: Map[String, Any] = Map("name" -> name) ++ props
  def withAge(age: Int): Animal = new Animal(name, props.updated("age", age)) // can't use built-in copy
}

case class Cat(name: String, lives: Int, props: Map[String, Any]) extends Animal(name, props) {
  override def properties: Map[String, Any] = Map("name" -> name, "lives" -> lives) ++ props
  override def withAge(age: Int): Cat = Cat(name, lives, props.updated("age", age))
}

However I'm far from satisfied by this. There is a lot of repetition and, even if I'm using inheritance, I'm not reusing any code. I've tried using this.type as a return type and even using zio-prelude subtyping capability but the persistent problem is that, at some point, the subclass doesn't get recognized correctly.

Is there a better way to do this without repetition and leveraging scala features?

Ideally I would like something like this

case class Animal(name: String, props: Map[String, Any]) {
  def properties: Map[String, Any] = Map("name" -> name) ++ props
  def withAge(age: Int): this.type = copy(props = props.updated("age", age))
}

final case class Cat(name: String, lives: Int, props: Map[String, Any]) extends Animal(name, props + ("lives" -> lives))

so that no duplication is taking place. Of course the following is not compiling though.

val myCat: Cat = Cat("murzic", 9, Map()).withAge(4)

Solution

  • package animalworld
    
    import scala.collection.mutable
    
    class Builder(val properties: mutable.HashMap[String, Any]) {
    
      def this() = this(mutable.HashMap.empty)
    
      def withName(name: String): Builder = {
        properties.put("name", name)
        this
      }
    
      def withAge(age: Int): Builder = {
        properties.put("age", age)
        this
      }
    
      def setProperty(key: String, value: Any): Builder = {
        properties.put(key, value)
        this
      }
    
      def build[A <: Animal](implicit buildable: Buildable[A]): A =
        buildable.build(this)
    }
    
    
    package animalworld
    
    trait Buildable[A <: Animal] {
      def build(builder: Builder): A
    }
    
    package animalworld
    
    import scala.collection.mutable
    
    class Animal protected (val name: String, val properties: Map[String, Any]) {
      def toBuilder: Builder = new Builder(mutable.HashMap.from(properties))
    }
    
    object Animal {
      implicit val animalBuildable: Buildable[Animal] = { builder =>
        new Animal(
          builder.properties("name").asInstanceOf[String],
          Map.from(builder.properties)
        )
      }
    }
    
    package animalworld
    
    class Cat protected (
        override val name: String,
        val lives: Int,
        override val properties: Map[String, Any]
    ) extends Animal(name, properties)
    
    object Cat {
      implicit val catBuildable: Buildable[Cat] = { builder =>
        new Cat(
          builder.properties("name").asInstanceOf[String],
          builder.properties("lives").asInstanceOf[Int],
          Map.from(builder.properties)
        )
      }
    }
    
    package animalworld
    
    object Main extends App {
      val animal1 = new Builder().withName("tim-tim").build[Animal]
    
      val cat1 = animal1.toBuilder.withAge(10).setProperty("lives", 9).build[Cat]
    
      println(animal1.name)
      // tim-tim
      println(animal1.properties)
      // Map(name -> tim-tim)
    
      println(cat1.name)
      // tim-tim
      println(cat1.lives)
      // 9
      println(cat1.properties)
      // Map(name -> tim-tim, lives -> 9, age -> 10)
    }