Restricting sum type instance creation

HKT HKT
Views

I love ADTs and the compile time guarantee of exhaustiveness. Makes your business logic robust. Unfortunately, while the robustness pertains to the type, how do you ensure the guarantee also applies to the value held by instance of the type? In other words, we should prevent creating rogue instances of sum types with invalid values.

Consider the following ADT to understand how users have provided their names. Ignore the logic behind determining how the different naming patterns are deduced. The parse method below is good enough to discuss the question in hand.

sealed trait Name

object Name {
  case class OnlyFirstName(value: String) extends Name

  case class FirstAndLastName(
    first: String,
    last: String
  ) extends Name
  
  case class FullName(
    first: String,
    middle: String,
    last: String
  ) extends Name
  
  def parse(raw: String): Validated[Name] = 
    if () new OnlyFirstName().valid
    else if () new FirstAndLastName().valid
    else if () new FullName().valid
    else "…".invalid
}

The definition is all good, provides the desired type robustness in narrowing down what exactly the user has provided as input. But there is a problem that the definition does not address.

It is still possible, anywhere in your application code, to create an instance of OnlyFirstName with a value that represents the full name. Might not be a big deal for user’s name but I am sure you would agree you need such a guarantee for a real domain model in real time.

Wouldn’t you like to have the absolute guarantee that the sum types can and will be only created as part of its definition (file where your sealed trait is defined)? Especially as part of and after input validation.

I bet you do. So, here is the trick to establish that guarantee:

// UserProvidedName.scala
sealed trait Name

object Name {
  sealed abstract case class OnlyFirstName(value: String) extends Name

  sealed abstract case class FirstAndLastName(
    first: String,
    last: String
  ) extends Name
  
  sealed abstract case class FullName(
    first: String,
    middle: String,
    last: String
  ) extends Name
  
  def parse(raw: String): Validated[Name] = 
    if (...) new OnlyFirstName(...) {}.valid
    else if (...) new FirstAndLastName(...) {}).valid
    else if (...) new FullName() {}.valid
    else "...".invalid
}
  • abstract - Prevent creating instances of the sum types; anonymous instances can still be created. See the parse method creating instances.
  • sealed - Prevent extending the types elsewhere but this file
  • case class - So that we can still pattern match sum types

Thus, the only way to create an instance of the sum types is by calling the parse method while maintaining the guarantees of sum types. 💥