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
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.compiletime
package:
codeOf(x)
returns the value of the parameterx
error(x)
pops the x string into as a compilation error message+
concatenates the value of the parameterx
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
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
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 typeString
.
Here, we useiban.type
because we are working with types, but this call does not returnString
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 anInt
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!