Scala 3 Opaque Types: When to use, examples, benefits

Scala 3 FAQ: What are opaque types in Scala?

Discussion

I previously wrote a little about Opaque Types in Scala 3, and today, as I’m working on a new video about opaque types, I thought I’d add some more information about them.

Note: I created this article in part through interactions with ChatGPT, while developing my Advanced Scala 3 video course.

Scala 3 Opaque Types

In Scala 3, opaque types are used to provide a way to define abstract data types with controlled access to their underlying representation. You should consider using opaque types in the following scenarios:

  1. Encapsulation: When you want to encapsulate the underlying representation of a type and only expose specific operations on that type. (Such as creating a Money type, whose internal representation is BigDecimal.)

  2. Type Safety: When you want to ensure type safety by restricting the operations that can be performed on a particular type, opaque types can help prevent accidental misuse.

  3. Abstraction: Related to the first two points, when you want to create new data types without exposing their internal representation, opaque types allow you to define clear interfaces while hiding implementation details.

  4. Semantic Clarity: When you want to improve the readability and clarity of your code by giving meaningful names to types — such as EmailAddress instead of String — opaque types allow you to create new types with descriptive names. This is consistent with practices of Domain-Driven Design (DDD) and Functional Programming (FP).

Note that in DDD and FP, instead of using the Scala String type to represent an email address, programmers will often create an EmailAddress type to achieve the benefits just stated. This approach is very similar to the following opaque type example.

Scala 3 Opaque Type Example

Here’s a Scala 3 example to illustrate the use of opaque types:

opaque type Kilometers = Double

object Kilometers:
    def apply(value: Double): Kilometers =
        assert(value >= 0, "Kilometers value cannot be negative")
        value

    extension (km: Kilometers)
        def toMiles: Miles = Miles(km * 0.621371)
end Kilometers

opaque type Miles = Double

object Miles:
    def apply(value: Double): Miles =
        assert(value >= 0, "Miles value cannot be negative")
        value

    extension (miles: Miles)
        def toKilometers: Kilometers = Kilometers(miles / 0.621371)
end Miles

// usage:
val distanceInKm: Kilometers = Kilometers(100)
val distanceInMiles: Miles = distanceInKm.toMiles
println(s"$distanceInKm kilometers is $distanceInMiles miles.")

In this example, we’ve defined opaque types Kilometers and Miles to represent distances. By using opaque types, we ensure that distances are always non-negative, and we provide specific conversion methods between kilometers and miles, using Scala 3 extension methods. This encapsulation, type safety, abstraction, and semantic clarity improve the robustness, safety, and clarity of our code.