xebia-functional / refine_types_scala3

Repository that explores the possibilities of Scala 3 features of opaque types and inline for type refinement.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Opaque Types and Inline

What are the possibilities of Scala 3 features opaque type and inline for type refinement?

This repository covers 4 levels of complexity:

The use case is based on a very simplistic model for a financial institution.

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.

How can we model better this domain? The primitive types do not help us much. Let's dive into the Scala 3 type system.

Basic

Source Code

The basic way will be to declare just some type aliases for the underlying types. It works the same in Scala 2. For example:

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

With these type aliases we could redefine our basic model to:

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

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

Doesn't it look better? Now we can create an instance like this:

val firstName: Name  = "John"
val middleName: Name = "Stuart"
val lastName: Name   = "Mill"
val iban: IBAN       = "GB33BUKB20201555555555"
val balance: Balance = -10

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

val account = Account(holder, iban, balance)

So, what are the benefits of using type aliases? Well, our code is more readable, and we can grasp faster what is going on. But that is about it. We can still use the underlying types' API. We have just gain some readability.

Additional information in Alvin Alexander's blog.

Standard

Source Code

Scala 3 includes a new way of declaring types that is cheaper in terms of overhead. Just add the soft keyword opaque in front of type. Now, the compiler only sees the opaque type during compilation. Thus, it does not know which is the underlying type until it compiles the code. This prevents us from accessing the API of the underlying primitive type and pushes us into creating our own API.

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

But, since the compiler does not see the underlying type... how do you create values of the opaque type? With an 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

For those unaware of the opaque types, this code could make them think that we are using there case class(es) as wrappers of other types.

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)

But we are not using case class(es). Once the code is compiled, those opaque types will be represented as their underlying type. There is no need for the creation of an instance of a class wrapper. That is the main benefit of this approach in Scala 3.

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

Advanced

Source Code

So now, what happens in real applications? Often times we will work with values that are unknown at runtime. Hence, we want certain kind of validation. We can achieve this with a new method in the companion object called from (it can be found also by safe in some codebases):

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)

What about those values that we know during compilation time? Is there a way that the compiler could tell us that the values fail the validation? Yes, there is a way in Scala 3. We will combine the soft keyword inline and the tools present in the package scala.compiletime.

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

Explanation: inline replaces 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 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 was 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 has to be a constant expression, and thus guarantees 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.

Full documentation on inlining at Scala 3 reference for metaprogramming.

Scala Magic

Source Code

How to implement refined types that are robust and maintainable? Well, first, the validation algorithm has to be robust and should be the same for the apply and from methods. Second, the error messages should be as similar as possible so the errors during runtime can be easily identified.

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

Balance

The bank decides that for the given accounts that our service will handle, there is a maximum and minimum amount of money allowed. These limits are -1,000€ and 1,000,000€. In this specific case, the same validation could be used in the apply method since the expression in the if can be reduced to true or false during compilation time. We will declare an inline def that will take as parameter the balance and return a boolean. For this to work we need a boolean expression that can be evaluated at compile time.

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

So we have the same validation. Now, do we have the same error message? Yes! For it to work, we have to inline the error message, so it can be reduced to a single string during compilation time.

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

In the apply we used codeOf(), error and +:

  • codeOf(x) returns the value of the parameter x
  • error(x) prints the x string into the console as a compilation error message
  • + concatenates the value of the parameter x and the rest of the error messages (that has to be inlined to work!)
error(codeOf(balance) + errorMessage)

In the from method we just return the concatenation of the parameter and the error message wrapped into a specific error case class:

Left(InvalidBalance(balance + errorMessage))

The complete implementation would look like this:

object Balance:

  private inline def validation(balance: Int): Boolean = balance >= -1000 && balance <= 1000000
  private inline val errorMessage = " is invalid. Balance should be equal or greater than -1,000 and equal or smaller than 1,000,000"

  inline def apply(balance: Int): Balance =
    inline if validation(balance)
    then balance
    else error(codeOf(balance) + errorMessage)

  def from(balance: Int): Either[InvalidBalance, Balance] =
    if validation(balance)
    then Right(balance)
    else Left(InvalidBalance(balance + errorMessage))

IBAN - International Bank Account Number

More info on IBAN

For the IBAN field, we will use the Spanish rule:

  • IBAN always starts with the country code "ES"
  • IBAN has a total length of 26 characters:
    • 2 letters (country code)
    • followed by 24 digits

We know that the apply method we cannot use substring or length since they are evaluated at runtime. How can we do it? Here, Scala 3 has a very handy package that will help us a lot: scala.compiletime.ops:

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)

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))

The real magic of inlining and the compile time API starts to show:

  • constValue[T]: returns the value of the type T. So, T in this case has to be of type Boolean.
  • 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

Name

Our final refined type will be Name. In Spain is very usual for people to have multiple first names and, at the same time, people do not categorized any of these names as middle name. Thus, our refinement has to be flexible while keeping some rules. So let's say that we want:

  • Names start with upper case followed by lower case
  • No empty spaces before or after the name
  • Name can contain multiple valid names separated by one white space

To do this validation we can use a regular expression following the Java standards.

object Name:

  /**
   * Explanation:
   *
   * `^` :Asserts the start of the string. [A-Z]: Matches an uppercase letter at the beginning of the string.
   * [a-zA-Z]*: Matches zero or more letters (uppercase or lowercase) after the first letter.
   * (?:\s[A-Z][a-zA-Z]*)*: Allows for zero or more occurrences of a space followed by an uppercase letter and zero or more lowercase/uppercase letters. $: Asserts the end of the string.
   */
  private inline val validation   = """^[A-Z][a-zA-Z]*(?:\s[A-Z][a-zA-Z]*)*$"""
  private 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))

With this approach, we have a common error message and validation logic expressed in an elegant way in just a few lines of code.

Conclusion

Leveraging the power of opaque types, inline and the compile time API, we can define refined types in Scala 3 that are precise and elegant. There is no need to use any other library than the language itself.

About

Repository that explores the possibilities of Scala 3 features of opaque types and inline for type refinement.

License:Apache License 2.0


Languages

Language:Scala 100.0%