tuleisM >>= Random.nextBlog
Posted 15 Aug 2020 by Linh Nguyen

Smart Constructors in Scala

Introduction

Sometimes, we need guarantees about the values in our program beyond what can be accomplished with the usual type system checks. Take for example an Email type, which contains an email address:

case class Email(address: String)

The problem with Email is that address can contain any value of type String, even if they are not valid email addresses. Due to that, code that uses Email cannot assume that it's always a valid email address. Thus, we want to:

  1. Constrain the set of possible value for address to be the set of valid email addresses.
  2. Make sure every instance of Email satisfies this constraint. In other words, every Email instance contains a valid email address.

Smart Constructors is one solution for this: instead of normal constructors, we force construction through "smart" functions that only return Email instances when the input passes validation.

In our case, we can use a function that returns Option[Email, Either[Error, Email], etc depends on the validation result. For demonstration, I use a very simple regex:

def fromString(v: String): Option[Email] =
  NaiveEmailRegex.findFirstIn(v).filter(_ == v).map(_ => new Email(v))

Currently, I'm aware of 3 ways to implement Smart Constructors in Scala.

The Straightforward One: sealed trait

Using trait, it is very straightforward to implement:

sealed trait Email {
  def value: String
}

object Email {
  def fromString(v: String): Option[Email] =
    NaiveEmailRegex.findFirstIn(v).filter(_ == v).map { _ =>
      new Email {
        override def value: String = v
      }
    }
}
  • With sealed, we disallow attempts to extends Email from outside of this source file.
  • Email.fromString is the only way to construct an Email from outside of this source file.

The Confusing One: final case class private

When it comes to modeling data, especially those that are immutable, case classes is the method of choice for Scala. Compared to traits, case classes come with many useful features out of the box: pattern matching, equality comparison, copying, etc.

On the other hand, some of these make it harder to implement smart constructors. The implementations also differ depends on which Scala version (and compiler flags) you are using, causing confusion for beginners.

Let's begin with an initial implementation in Scala 2.11:

final case class Email private (value: String)

object Email {
  def fromString(v: String): Option[Email] = ...
}
  • With final, we disallow attempts to extends Email.
  • With private, construction through new Email(value) can only takes place within the current source file.

However, it is still possible to create an invalid Email instance:

  • By using copy(value = "badValue") to create a shallow copy of an instance of Email with a bad value.
  • By using Email(value = "badValue"), which translates to calling apply() on the Email's companion object to construct an Email with a bad value.

To fix these problems, we need to either hide or override the copy() and apply() functions. For example, with apply(), there are 2 choices:

  • Override it with private modifier to hide the apply() function, disallowing construction through Email(value).
  • Override it with an alternative return type and implementation: Email(value) is possible, but let's say, return Option[Email] instead.

2.11.x and 2.12.x

It turns out it is not possible to override apply() in vanilla Scala 2.11.x. See this StackOverflow question for more details.

To do that, we need to add the compiler flag -Xsource:2.12, which is only available after 2.11.11:

Allow custom apply and unapply methods in case class companions. Also allows case class constructors and apply methods to be private. (In 2.11.11, -Xsource:2.12 is needed to enable these changes. In Scala 2.12.2, they are on by default.)

With this, our code becomes:

final case class Email private (value: String) {
  private def copy(): Unit = ()
}

object Email {
  private def apply(value: String): Email = ???

  def fromString(v: String): Option[Email] = ...
}

This is the same for 2.12.x without any compiler flag.

2.13.x and Dotty

With vanilla 2.13.x, the code is the same as 2.12.x. However, from 2.13.2, we can reduce the boilerplate by enabling -Xsource:3. From scala/scala#7702:

Backport from dotty:

  • If a case class constructor is private or private[foo]: the synthesized copy and apply methods will have the same access modifier.
  • If a case class constructor is protected or protected[foo]: the synthesized copy method will have the same access modifier. The synthesized apply method will remain public, because protected doesn't make sense in an object. Obviously, if a user defines a custom copy or apply method, that one—including its access modifier—will take precedence.

We end up with the original code that we begin with:

final case class Email private (value: String)

object Email {
  def fromString(v: String): Option[Email] = ...
}

Phew! It only takes a few Scala major releases to get what we want.

The Tricky One: sealed abstract case class private

We don't normally see abstract being used on a case class. However, for smart constructors, it works wonderfully:

sealed abstract case class Email private (value: String)

object Email {
  def fromString(v: String): Option[Email] =
    NaiveEmailRegex.findFirstIn(v).filter(_ == v)
      .map(_ => new Email(v) {})
}
  • With sealed, we disallow attempts to extends Email from outside this source file.
  • With abstract, we disallow construction through new Email().
  • With abstract, copy() and apply() are not automatically generated. We don't have to worry about them.
  • It just work™! See the original gist for more details.

Testing

We verify that our smart constructors implementation works by using a test suite to check that allowed code Compiles and the rest DoesNotCompile.

Below is the test suite for sealed abstract case class private. The rest can be found in the code repository.

"AbstractCaseClassExample" in {
  import AbstractCaseClassExample.Email
  val email = Email.fromString(exampleEmail).value // apply now return Option
  email.value mustBe exampleEmail // access ok

  // case class's unapply()
  assertCompiles(
    """
      |email match { case Email(value) => value }
      |""".stripMargin
  )
  // companion's apply()
  assertDoesNotCompile(
    """
      |Email(exampleEmail)
      |""".stripMargin
  )
  // public constructor
  assertDoesNotCompile(
    """
      |new Email(exampleEmail)
      |""".stripMargin
  )
  // extends trait
  assertDoesNotCompile(
    """
      |new Email {
      |  override def value: String = exampleEmail
      |}
      |""".stripMargin
  )
  // case class's copy()
  assertDoesNotCompile(
    """
      |email.copy(value = exampleEmail)
      |""".stripMargin
  )
}

Summary

All of these allow us to implement smart constructors. My favorite one is sealed abstract case class private because:

  • It allows us to use case class (instead of trait, which is more verbose) and most of its goodness.
  • It looks the same in all Scala version. Cross-building is a breeze.