Hordon Freeman
Hordon Freeman

Reputation: 59

Scala Builder pattern with phantom types

Having the below builder pattern in Scala. To simplify it, I'm using 3 instances of A such that instance1 contains only field1 and has no connection to field2 or field3. The problem is that everywhere in the code I have to use val s = A.instance1.field1.get; doSomething(s), where the get call is not potentially safe. For example A.instance1.field2.get would fail on None.get. In order to guard it I have to match case against the option and deal with None cases:

object A {
  val instance1 = new ABuilder().withField1("abc").build1
  val instance2 = new ABuilder().withField1("abc").withField2("def").build2
  val instance3 = new ABuilder().withField1("abc").withField3("def").build1
}

case class A(builder: ABuilder) {
  val field1: Option[String] = builder.field1
  val field2: Option[String] = builder.field2
  val field3: Option[String] = builder.field3
}

class ABuilder {
  var field1: Option[String] = None
  var field2: Option[String] = None
  var field3: Option[String] = None
  def withField1(f: String): ABuilder = {
    this.field1 = Some(f)
    this
  }
  def withField2(f: String): ABuilder = {
    this.field2 = Some(f)
    this
  }
  def withField3(f: String): ABuilder = {
    this.field3 = Some(f)
    this
  }
  def build1: A = {
    require(field1.isDefined, "field 1 must not be None")
    A(this)
  }
  def build2: A = {
    require(field1.isDefined, "field 1 must not be None")
    require(field2.isDefined, "field 2 must not be None")
    A(this)
  }
}

Another solution would be to use parameterized types, also called phantom types. I found very few good tutorials on that subject, and could not find in any of them how to implement a type safe builder pattern in Scala with phantom types and actual data (or state) - all examples describe methods only.

How can I use phantom types in my example to avoid getting runtime None exceptions and get only nice type-mismatch exceptions? I'm trying to parameterize all the classes and methods mentioned and use sealed traits but had no success so far.

Upvotes: 0

Views: 1031

Answers (2)

Onilton Maciel
Onilton Maciel

Reputation: 3699

If you really want to use phantom types you could do

object PhantomExample {
  sealed trait BaseA
  class BaseAWith1 extends BaseA
  final class BaseAWith12 extends BaseAWith1

  object A {
    val instance1 = new ABuilder().withField1("abc").build1
    val instance2 = new ABuilder().withField1("abc").withField2("def").build2
  }

  case class A[AType <: BaseA](builder: ABuilder) {
    def field1[T >: AType <: BaseAWith1] = builder.field1.get
    def field2[T >: AType <: BaseAWith12] = builder.field2.get
  }

  class ABuilder {
    var field1: Option[String] = None
    var field2: Option[String] = None
    def withField1(f: String): ABuilder = {
      this.field1 = Some(f)
      this
    }
    def withField2(f: String): ABuilder = {
      this.field2 = Some(f)
      this
    }
    def build1: A[BaseAWith1] = {
      require(field1.isDefined, "field 1 must not be None")
      A(this)
    }
    def build2: A[BaseAWith12] = {
      require(field1.isDefined, "field 1 must not be None")
      require(field2.isDefined, "field 2 must not be None")
      A(this)
    }
  }

  val x = A.instance1.field1                      //> x  : String = abc
  val x2 = A.instance2.field1                     //> x2  : String = abc
  val x3 = A.instance2.field2                     //> x3  : String = def

  // This gives compilation error
  //val x2 = A.instance1.field2
}

However, I don't recommend using this kind of code in production. I think it looks ugly, the compilation error seems cryptic, and IMHO is not the best solution. Think about it, if your instances are so different, maybe they are not even instances of the same concrete class?

trait BaseA {
  def field1
}
class A1 extends BaseA { }
class A2 extends BaseA { ... def field2 = ... }

Upvotes: 1

juanitodread
juanitodread

Reputation: 156

I'm not sure if this is what you want, but i think you can take it as a base.

First the A class:

case class A(field1: String = "", 
              field2: String = "",
              field3: String = "")

The case class has default values of empty strings. This allow us to create any A object with any field value assigned without care of None values.

For example:

val b2 = A("abc", "def")
> b2: A = A(abc,def,)

val b1 = A("abc")
> b1: A = A(abc,,)

val notValidB = A(field2 = "xyz")
> notValidB: A = A(,xyz,)

As you can see b2 and b1 are valid objects, and notValidB is not valid since your object requires field1.

You can create another function that uses pattern matching to validate your A objects and then proceed with determinate actions.

def determineAObj(obj: A): Unit = obj match {
  case A(f1, f2, _) if !f1.isEmpty && !f2.isEmpty => println("Is build2")
  case A(f1, _, _) if !f1.isEmpty => println("Is build1")
  case _ => println("This object doesn't match (build1 | build2)")
}

And then run:

determineAObj(b1)
> "Is build1"

determineAObj(b2)
> "Is build2"

determineAObj(notValidB)
> "This object doesn't match (build1 | build2)"

Upvotes: 0

Related Questions