Search code examples
scalabuildersubtyping

Type safe Scala Builder pattern with special rules


I'm trying to create a type safe builder of a case class , where its params can be of following types:

  1. required
  2. optional
  3. required but mutually exclusive -> a. ex. lets say I've 3 params: (param1), (param2, param3). If I have param1, I cannot set param2 or param3. If I can set both param2 and param3, but I cannot set param1
  4. optional but mutually exclusive -> same logic as above but these are optional params. That is optionally I can set either param1 or (param2 and param3).

I figured how to get required and optional cases, but cannot get case 3 and 4. Any ideas how to proceed.

These checks can be done at runtime, but I want these set of rules being implemented at compile time

case class Person(name: String, /*required*/
                  address: String, /*optional*/
                  city: String, /* reqd exclusive*/
                  county: String, /* reqd exclusive*/
                  state: String /* reqd exclusive*/,
                  ssn: String, /* optional exclusive*/
                  insurance: String, /* opt exclusive*/
                  passport: String /* opt exclusive*/)
// where (city) and (county, state) are required but are mutually exclusive
// (ssn) and (insurance, passport) are optional but are mutually exclusive. 
// If I set passport, I've to set insurance

sealed trait PersonInfo
object PersonInfo {
  sealed trait Empty extends PersonInfo
  sealed trait Name extends PersonInfo
  sealed trait Address extends PersonInfo
  type Required = Empty with Name with Address
}

case class PersonBuilder[T <: PersonInfo]
(name: String = "", address: String = "", city: String = "", county: String = "", 
  state: String = "", ssn: String = "", insurance: String = "",passport: String ="") {

  def withName(name: String): PersonBuilder[T with PersonInfo.Name] =
    this.copy(name = name)

  def withTask(address: String): PersonBuilder[T with PersonInfo.Address ] =
    this.copy(address = address)

  def withCity(city: String): PersonBuilder[T] =
    this.copy(city = city)

  def withCountry(county: String): PersonBuilder[T] =
    this.copy(county = county)

  def withState(state: String): PersonBuilder[T] =
    this.copy(state = state)

  def withSsn(ssn: String): PersonBuilder[T] =
    this.copy(ssn = ssn)

  def withInsurance(insurance: String): PersonBuilder[T] =
    this.copy(insurance = insurance)

  def withPassport(passport: String): PersonBuilder[T] =
    this.copy(passport = passport)

  def build(implicit ev: T =:= PersonInfo.Required): Person =
    Person(name, address, city, county, state, ssn, insurance, passport)
}

here's the build

val testPerson = PersonBuilder[PersonInfo.Empty]()
    .withName("foo")
    .withSsn("bar")

Solution

  • As mentioned in a comment, if creating a builder is not a hard requirement, a viable choice could be to make those requirements explicit in the types, using sum types for exclusive choices and Options for optional ones, as in the following example:

    sealed abstract class Location extends Product with Serializable {
      def value: String
    }
    
    object Location {
      final case class City(value: String) extends Location
      final case class County(value: String) extends Location
      final case class State(value: String) extends Location
    }
    
    sealed abstract class Identity extends Product with Serializable {
      def value: String
    }
    
    object Identity {
      final case class Ssn(value: String) extends Identity
      final case class Insurance(value: String) extends Identity
      final case class Passport(value: String) extends Identity
    }
    
    final case class Person(
        name: String,
        address: Option[String],
        location: Location,
        identity: Option[Identity],
    )
    

    Scala 3 further introduced enums which makes the definition more compact and readable:

    enum Location(value: String) {
      case City(value: String) extends Location(value)
      case County(value: String) extends Location(value)
      case State(value: String) extends Location(value)
    }
    
    enum Identity(value: String) {
      case Ssn(value: String) extends Identity(value)
      case Insurance(value: String) extends Identity(value)
      case Passport(value: String) extends Identity(value)
    }
    
    final case class Person(
        name: String,
        address: Option[String],
        location: Location,
        identity: Option[Identity],
    )
    

    And making Options default to None you get a very similar experience to custom-made builders without any additional code:

    final case class Person(
        name: String,
        location: Location,
        address: Option[String] = None,
        identity: Option[Identity] = None,
    )
    
    Person("Alice", Location.City("New York"))
      .copy(identity = Some(Identity.Ssn("123456")))
    

    Which you can further refine very easily:

    final case class Person(
        name: String,
        location: Location,
        address: Option[String] = None,
        identity: Option[Identity] = None
    ) {
    
      def withAddress(address: String): Person =
        this.copy(address = Some(address))
    
      def withIdentity(identity: Identity): Person =
        this.copy(identity = Some(identity))
    
    }
    
    Person("Alice", Location.City("New York")).withIdentity(Identity.Ssn("123456"))
    

    You can play around with this code here on Scastie.