Scala 3 opaque types: How to create meaningful type names

This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 23.7, Creating Meaningful 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, and Double.

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 second validate 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, and password 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 the CanEqual recipe for more details on using CanEqual.)

  • 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 named CustomerId. (Behind the scenes, a CustomerId is an Int.)

  • The object with the apply method creates a factory method (constructor) for new CustomerId instances.

  • The given CanEqual declaration states that a CustomerId can only be compared to another CustomerId. Attempting to compare a CustomerId to a ProductId or Int 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.

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 an Int.)
  • Outside the scope only the defined alias is visible. (Outside that scope, other code can’t tell that CustomerId is really an Int.)

As an important note for high-performance situations, the SIP also states, “Opaque type aliases are compiled away and have no runtime overhead.”