Blog

Crafting Concise Constructors with Opaque Types in Scala 3

13 Nov, 2023
Xebia Background Header Wave

Opaque Types and Inline

In the vast realm of programming languages, Scala stands as a versatile choice, and one of its often-underestimated gems is the concept of opaque types in Scala 3. These allow developers to create abstract data types while maintaining encapsulation and type safety.

In this post, we will explore the nuances of defining apply and from methods within the context of opaque types, enabling us to harness the full power of Scala's type system.

Let's use a basic data model for a financial institution as an example.

final case class AccountHolder(firstName: String, middleName: Option[String], lastName: String, secondLastName: Option[String])

final case class Account(accountHolder: AccountHolder, iban: String, balance: Int)

Disclaimer: Use a more adequate type for the Balance in your production code. Do not use Int.

Now, let's explore how to model this domain more effectively using Scala's type system.

Basic Level

At the basic level, you can define type aliases for the underlying types. This approach enhances code readability and clarifies the intention:

  type Name    = String
  type IBAN    = String // International Bank Account Number
  type Balance = Int

With these type aliases, the basic model becomes:

final case class AccountHolder(firstName: Name, middleName: Option[Name], lastName: Name, secondLastName: Option[Name])

final case class Account(accountHolder: AccountHolder, iban: IBAN, balance: Balance)

This approach doesn't change the underlying types' API but significantly improves code readability.

Additional information is in Alvin Alexander's blog.

Standard Level

Scala 3 introduces a more efficient way of declaring types using the opaque keyword. It allows you to define an opaque type and only reveal its underlying type once the code is compiled.

opaque type Name    = String
opaque type IBAN    = String
opaque type Balance = Int

To create instances of opaque types, you use the apply method in the companion object:

object Name:
  def apply(name: String): Name = name

object IBAN:
  def apply(iban: String): IBAN = iban

object Balance:
  def apply(balance: Int): Balance = balance

This approach maintains encapsulation and provides a cleaner interface for creating instances of opaque types in Scala 3, enhancing code safety and maintainability.

Example for standard level:

val firstName: Name  = Name("John")
val middleName: Name = Name("Stuart")
val lastName: Name   = Name("Mill")
val iban: IBAN       = IBAN("GB33BUKB20201555555555")
val balance: Balance = Balance(123)

val holder: AccountHolder = AccountHolder(firstName, Some(middleName), lastName, None)

val account: Account = Account(holder, iban, balance)

Additional information is in the Scala 3 Documentation, and Alvin Alexander's blog.

Advanced

Source Code

In real applications, you often need to work with values unknown at runtime, requiring specific validation.

You can achieve this by adding a from method to the companion object.

This method allows you to validate and construct opaque types in Scala 3 based on runtime values:

final case class InvalidName(message: String) extends RuntimeException(message) with NoStackTrace

opaque type Name = String

object Name:

  def from(fn: String): Either[InvalidName, Name] =
  // Here we can access the underlying type API because it is evaluated during runtime.
    if fn.isBlank | (fn.trim.length < fn.length)
    then Left(InvalidName(s"First name is invalid with value <$fn>."))
    else Right(fn)

We use the inline keyword before def and before if for values known at compile time. For better error tracing, we will use the API available at scala.compiletimepackage:

  • codeOf(x) returns the value of the parameter x
  • error(x) pops the x string into as a compilation error message
  • + concatenates the value of the parameter x and the rest of the error messages
inline def apply(name: String): Name =
  inline if name == ""
  then error(codeOf(name) + " is invalid.")
  else name

This combination of inline and compiletime tools allows for comprehensive validation during compilation and runtime, ensuring the robustness of your data models.

Why is the inline keyword here so important?

It makes the compiler replace the right-hand side where the left-hand side is called. The inline if will evaluate the condition during compile time. If true, will rewrite the apply as:

inline def apply(name: String) = error(codeOf(name) + " is invalid.")

So, if we try to write something like this:

val firstName: Name  = Name("")

It will replace the right-hand side of the def apply (because it is also inlined) during compilation time to:

val firstName: Name  = error(codeOf("") + " is invalid.")

And we will get a compiler error:

[error] -- Error: /opaque_types_and_inline/03-advanced/src/main/scala/dagmendez/advanced/Main.scala:12:39 
[error] 12 |    val firstName: Name  = Name("")
[error]    |                                   ^^^^^^^^
[error]    |                                   "" is invalid.
[error] one error found
[error] (advanced / Compile / compileIncremental) Compilation failed

So now that we are using the two methods apply and from, we can validate known and unknown values during compilation and runtime. But . . . the validation on the apply method is different from the one in the from method. Why?

An if-then-else expression whose condition is a constant expression can be simplified to the selected branch. Prefixing an if-then-else expression with inline enforces that the condition must be a constant expression, thus guaranteeing that the conditional will always simplify.

The methods used in the from method are evaluated at runtime, so they cannot be reduced to a constant expression. If we try to compile the same validation in the apply method, the compiler won't allow us.

Check out the full documentation on inlining at Scala 3 reference for metaprogramming.

Scala Magic

Source Code

How do we implement refined types that are robust and maintainable? First, the validation algorithm must be robust and should be the same for the apply and from methods. Second, the error messages should be as similar as possible to identify errors during runtime easily.

So let's go and check our refined types one by one.

Balance

Consider a scenario where a bank defines certain limits for account balances: a minimum of -1,000€ and a maximum of 1,000,000€. We want to ensure that the validation is the same for both the apply and from methods.

We can use the same validation in the apply method in this specific case, as the condition can be evaluated at compile time. We declare an inline method that takes the balance as a parameter and returns a boolean. For this to work, we need a boolean expression that can be evaluated at compile time:

inline def validation(balance: Int): Boolean = 
  balance >= -1000 &&
  balance <= 1000000

Now, how about the error message?

We want it to be consistent, easily identifiable, and reduced to a single string during compilation time. We achieve this with the following code:

inline val errorMessage = " is invalid. Balance should be equal or greater than -1,000 and equal or smaller than 1,000,000"

So, the error message can be inlined and reduced to a single string. This allows us to create concise and expressive error messages, such as:

error(codeOf(balance) + errorMessage)

In the from method, we return a concatenation of the parameter and the error message wrapped in a specific error case class. This approach ensures a consistent error mechanism:

Left(InvalidBalance(balance + errorMessage))

IBAN - International Bank Account Number

More info on IBAN

The IBAN is an international standard for bank account numbers, and different countries have specific rules for IBAN formats. In our case, we'll use the Spanish rule, which dictates that an IBAN always starts with the country code "ES" and has a total length of 26 characters.

In the from method, we perform runtime checks to ensure that the IBAN is valid.

def from(iban: String): Either[InvalidIBAN, IBAN] =
  if 
    iban.substring(0, 2) == "ES" && 
    iban.length == 26 &&
    iban.substring(2, 25).matches("^\\d*$")
  then Right(iban)
  else Left(InvalidIBAN(iban + errorMessage))

In the apply method, we cannot use methods like substring or length because they are evaluated at runtime. Scala 3 comes to the rescue with the scala.compiletime.ops package, providing the tools we need.

The real magic of inlining and the compile-time API shines through!

We use the inline keyword and the constValue macro to perform compile-time checks. This approach ensures that the IBAN conforms to the required format:

inline def apply(iban: String): IBAN =
  inline if constValue[
    Substring[iban.type, 0, 2] == "ES" &&
    Length[iban.type] == 26 &&
    Matches[Substring[iban.type, 2, 25], "^\\d*$"]
  ]
  then iban
  else error(codeOf(iban) + errorMessage)

We are working at the type level here, so let's see what the translations are that the compiler performs:

  • Substring[String, Int, Int]: returns the value of the substring as a type String. Here, we use iban.type because we are working with types, but this call does not return String but the value itself as a literal type.
val iban: ES012345678901234567890123 = "ES012345678901234567890123"
val condition: Boolean = Substring[iban.type, 0, 2] == "ES"
val condition: Boolean = Substring[ES012345678901234567890123, 0, 2] == "ES"
val condition: Boolean = ES == "ES"
val condition: Boolean = "ES" == "ES" //ES is converted to its value
val condition: Boolean = true
  • Length[String]: returns the length of the string as an Int
    val iban: ES012345678901234567890123 = "ES012345678901234567890123"
    val condition: Boolean = Length[iban.type] == 26
    val condition: Boolean = Length[ES012345678901234567890123] == 26
    val condition: Boolean = 26 == 26 //Type 26 is converted to its value
    val condition: Boolean = true

Isn't this magical?

Name

Names can be a complex matter, especially in countries where people have multiple first names and don't categorize them as middle names. We need a flexible yet robust validation mechanism. For our refined type, we specify the following rules:

  • Names should start with an uppercase letter followed by lowercase letters.
  • There should be no empty spaces before or after the name.
  • Names can contain multiple valid names separated by white spaces.

To validate names, we use a regular expression following Java standards. Here's a glimpse at how we achieve this:

object Name:

  inline val validation   = """^[A-Z][a-zA-Z]*(?:\s[A-Z][a-zA-Z]*)*$"""
  inline val errorMessage = " is invalid. It must: \n - be trimmed.\n - start with upper case.\n - follow upper case with lower case."

  inline def apply(fn: String): Name =
    inline if constValue[Matches[fn.type, validation.type]]
    then fn
    else error(codeOf(fn) + errorMessage)

  def from(fn: String): Either[InvalidName, Name] =
    if validation.r.matches(fn)
    then Right(fn)
    else Left(InvalidName(fn + errorMessage))

This approach provides us with a common error message and validation logic expressed elegantly in just a few lines of code.

Conclusion

Scala's opaque types, inline, and the compile-time API offer us the power to define refined types that are both precise and elegant. What's truly magical is that you don't need external libraries; the Scala language itself equips you with the tools to create robust and maintainable data models.

With this knowledge, you can elevate your Scala programming skills and craft more reliable and expressive code. So go forth and harness the magic of Scala for refined types that are both robust and maintainable. Your code will thank you!

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts