This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 23.7, Creating Meaningful Scala Type Names with Opaque Types.
Problem
In keeping with practices like Domain-Driven Design (DDD), you want to give values that have simple types like String
and Int
more meaningful type names to make your code safer and easier to read.
Solution
In Scala 3, the solution is to use opaque types to create meaningful type names. For an example of the problem, when a customer orders something on an e-commerce website you may add it to a cart using the customerId
and the productId
:
def addToCart(customerId: Int, productId: Int) = ...
Because both types are Int
, it’s possible to confuse them. For instance, developers will call this method with integers like this:
addToCart(1001, 1002)
Because both fields are integers, it’s possible to confuse them again later in the code:
// are you sure you have the right id here?
if (id == 1000) ...
The solution to this problem is to create custom types as opaque types. A complete solution looks like this:
object DomainObjects:
opaque type CustomerId = Int
object CustomerId:
def apply(i: Int): CustomerId = i
given CanEqual[CustomerId, CustomerId] = CanEqual.derived
opaque type ProductId = Int
object ProductId:
def apply(i: Int): ProductId = i
given CanEqual[ProductId, ProductId] = CanEqual.derived
This lets you write code like this:
@main def OpaqueTypes =
// import the types
import DomainObjects._
// use the `apply` methods
val customerId = CustomerId(101)
val productId = ProductId(101)
// use the types
def addToCart(customerId: CustomerId, productId: ProductId) = ...
// pass the types to the function
addToCart(customerId, productId)
The “given CanEqual
” portion of the solution also creates a compiler error if you attempt incorrect type comparisons at some future time:
// error: values of types DomainObjects.CustomerId and Int
// cannot be compared with == or !=
if customerId == 1000
// also an error: this code will not compile
if customerId == productId ...
Discussion
When you work in a Domain-driven design (DDD) style, one of the goals is that the names you use for your types should match the names used in the business domain. For example, when it comes to variable types you can say:
-
A domain expert thinks about things like
CustomerId
,ProductId
,Username
,Password
,SocialSecurityNumber
,CreditCardNumber
, etc. -
Conversely, they don’t think about things like
Int
,String
, andDouble
.
Beyond DDD, an even more important consideration is functional programming (FP). One of the benefits of writing code in a functional style is that other programmers should be able to look at our function signatures and quickly see what our function does. For example, take this function signature:
def f(s: String): Int
Assuming the function is pure, we see that it takes a String
and returns an Int
. Given only those facts we can quickly deduce that the function probably does one of these things:
-
Determines the string length
-
Does something like calculating the checksum of the string
We also know that the function doesn’t attempt to convert the string to an int, because that process can fail, so a pure function that converts a string to an int will return the possible result in an error-handling type, like this:
def f(s: String): Option[Int]
def f(s: String): Try[Int]
def f(s: String): Either[Throwable, Int]
Given that pure function signatures are so important, we also don’t want to write types like this:
def validate(
username: String,
email: String,
password: String
)
Instead, our code will be easier to read and much more type-safe if we create our types like this:
def validate(
username: Username,
email: EmailAddress,
password: Password
)
This second approach — using opaque types — improves our code in several ways:
-
In the first example, all three parameters are strings, so it can be easy to call
validate
with the parameters in the wrong order. Conversely, It will be much more difficult to pass the parameters into the secondvalidate
method in the wrong order. -
The
validate
type signature will be much more meaningful to other programmers in their IDEs and in the Scaladoc. -
We can add validators to our custom types, so we can validate the
username
,email
address, andpassword
fields when they are created. -
By deriving
CanEqual
when creating opaque types, you can make it impossible for two different types to be compared using==
and!=
. (See theCanEqual
recipe for more details on usingCanEqual
.) -
Your code more accurately reflects the verbiage of the domain.
As shown in the Solution, opaque types are a terrific way to create types like Username
, EmailAddress
, and Password
.
Benefits of the three-step solution
The code in the solution looks like this:
opaque type CustomerId = Int
object CustomerId:
def apply(i: Int): CustomerId = i
given CanEqual[CustomerId, CustomerId] = CanEqual.derived
While it’s possible to create an opaque type with this one line of code:
opaque type CustomerId = Int
each step in the three-step solution serves a purpose:
-
The
opaque type
declaration creates a new type namedCustomerId
. (Behind the scenes, aCustomerId
is anInt
.) -
The
object
with theapply
method creates a factory method (constructor) for newCustomerId
instances. -
The
given CanEqual
declaration states that aCustomerId
can only be compared to anotherCustomerId
. Attempting to compare aCustomerId
to aProductId
orInt
will create a compiler error; it’s impossible to compare them.
History
There were several attempts to try to achieve a similar solution in Scala 2:
- Type aliases
- Value classes
- Case classes
Unfortunately all of these approaches had weaknesses, as described in the Opaque Types SIP. The goal of opaque types, as described in that SIP, is that “operations on these wrapper types must not create any extra overhead at runtime while still providing a type safe use at compile time.” Opaque types in Scala 3 have achieved that goal.
this post is sponsored by my books: | |||
#1 New Release |
FP Best Seller |
Learn Scala 3 |
Learn FP Fast |
Rules
There are a few rules to know about opaque types:
- They must be defined within the scope of an object, trait, or class.
- The type alias definition is visible only within that scope. (Within that scope, your code can see that a
CustomerId
is really anInt
.) - Outside the scope only the defined alias is visible. (Outside that scope, other code can’t tell that
CustomerId
is really anInt
.)
As an important note for high-performance situations, the SIP also states, “Opaque type aliases are compiled away and have no runtime overhead.”
Alternative
If you like Scala 3 opaque types, check out the Neotype project as an improvement over opaque types.
More information
Here’s a link to my free Scala 3 Opaque Types training video.