1. Introduction

The much-awaited Scala 3 release is almost here. At the time of writing this article, Scala 3 is already in RC3 release, and the final version is expected to be released soon. Many new features are coming up in Scala 3.

In this tutorial, we’ll discuss one of the most useful features in Scala 3 — opaque type aliases.

2. Similar Concepts in Scala 2

Opaque type alias is a feature that helps to implement domain-specific models in a better way. Before going in-depth about opaque types, let’s discuss some of the similar concepts in Scala 2.

2.1. Domain Modeling

Let’s assume that we’re building an application for a movie database. We can create a simple case class for Movie as:

final case class Movie(name: String, year: Int, runningTime: Int, noOfOscarsWon: Int)

Here, we have three fields of type Int. There’s a chance that, while populating the entity, we might accidentally fill the year field with the runningTime value. But, the Scala compiler will not return any compilation errors or warnings since both the fields are of type Int. We’ll have the same problem even if we use a type alias.

Another way is by creating separate case classes for each of the Int fields:

case class Year(year:Int)
case class RunningTimeInMin(runningTime:Int)
case class NoOfOscarsWon(noOfOscarsWon:Int)
final case class Movie(name: String, year: Year, runningTime: RunningTimeInMin, 
  noOfOscarsWon: NoOfOscarsWon)

Now, the Scala compiler will complain if the wrong value type is set. However, there’s an additional overhead at runtime to handle all these new instances of case classes.

2.2. Value Classes

To model the domain entities more safely and efficiently, Scala introduced the concept of value classes. Value classes are a special type of classes that provide type safety without creating additional objects at runtime.

Let’s rewrite the above case using value classes:

case class Year(year:Int) extends AnyVal
case class RunningTimeInMin(runningTime:Int) extends AnyVal
case class NoOfOscarsWon(noOfOscarsWon:Int) extends AnyVal
final case class Movie(name: String, year: Year, runningTime: RunningTimeInMin, noOfOscarsWon: NoOfOscarsWon)

The only difference is that Year, RunningTimeInMin, and NoOfOscarsWon are now value classes because they are extending AnyVal. The Scala compiler will erase these value classes at runtime and replace them with its inner type, in this case, Int. However, there is some performance overhead in certain scenarios:

  • object instantiation happens when using pattern matching
  • object instantiation happens when assigned to a List or Array

3. Opaque Type Alias

The opaque type alias is introduced in Scala 3 to provide type abstraction without any overhead to solve the above-mentioned issues.

3.1. Creating an Opaque Type Alias

To create an opaque type alias, we’ll use the keyword opaque. Let’s create an opaque type, Year, for our Movie entity:

object types {
  opaque type Year = Int
}

We’ve now created a new opaque type that is equivalent to an Int within the scope where it is defined. But outside the scope where it is defined, Year and Int are not the same, hence the name opaque type alias. So, if we write the below statement within the object types, the compilation will be successful:

val year: Year = 1999

But the same statement outside the object types will not compile.

3.2. Assigning a Value to an Opaque Type

Unlike built-in types, opaque types don’t have apply methods. Also, they don’t expose any methods of the original type.

In our example, even though Year is an opaque type alias for Int, none of the methods or operators of Int are accessible.

Now, let’s see how we can set a value for Year. For that, we’ll create a companion object for Year and provide an apply method:

opaque type Year = Int

object Year {
  def apply(value: Int): Year = value
}

Note that the apply method simply assigns an Int value directly. This is only possible because it’s within the scope of the definition. Now, we can assign a value to year as:

val year: Year = Year(2000)

3.3. Extracting the Value from an Opaque Type

Even though Year is an opaque type alias for Int, we might need to extract the Int value from it. Since there are no methods exposed, we need to add them explicitly. We can use the newly introduced extension methods to provide such utility functions.

Let’s add an extension method to the companion object of our opaque type Year:

extension (year: Year) {
  def value: Int = year
}

Now, we can access the underlying Int value easily:

val year: Year = Year(2000)
assert(year.value == 2000)

3.4. Adding Safe Operations

The main reason for using the opaque type alias is to model the domain entities more safely. We might need to provide some restrictions to the values as per the domain. Instead of just applying the value, we can provide methods that handle the domain constraints and build the objects safely.

For example, we can implement a safe method using extension methods:

def safe(value: Int): Option[Year] = if (value > 1900) Some(value) else None

We can provide such safe methods to all the opaque types, thereby avoiding such explicit checks while building the domain entity.

Let’s now rewrite our Movie entity to use these safe methods:

val spaceOdyssey = for {
  year <- Year.safe(1968)
  runningTime <- RunningTimeInMin.safe(149)
  noOfOscars <- NoOfOscarsWon.safe(1)
}yield Movie("2001: A Space Odyssey", year, runningTime, noOfOscars)

If any of the domain constraints are not met, the value of spaceOdyssey will be empty.

4. Context Bounds

Similar to other types, we can provide context bounds to the opaque type alias as well. Let’s say that we are creating an opaque type for ReleaseDate with a context-bound:

opaque type ReleaseDate <: LocalDate = LocalDate
object ReleaseDate {
  def apply(date: LocalDate): ReleaseDate = date
}

Now, we’ll be able to access all the methods of LocalDate without creating any extension methods:

val date = LocalDate.parse("2021-04-20")
val releaseDate = ReleaseDate(date)
assert(releaseDate.getYear() == 2021)

Similar to normal types, we can use another opaque type as a context-bound:

opaque type NetflixReleaseDate <: ReleaseDate = ReleaseDate

Now, we can use an instance of NetflixReleaseDate in place of ReleaseDate. However, we’ll need to explicitly implement a companion object for NetflixReleaseDate. Note that NetflixReleaseDate won’t inherit any methods in ReleaseDate. But, the extension methods for ReleaseDate can be applied for NetflixReleaseDate.

5. Properties of Opaque Type Aliases

The major differences of opaque type aliases from value classes are:

  • Opaque types have no APIs implemented by default — including apply and toString
  • No access to underlying type’s APIs, unless a context-bound is applied
  • Opaque types don’t support pattern matching
  • Opaque types are completely erased at runtime

6. Conclusion

In this tutorial, we looked at the newly introduced opaque type alias in Scala 3. We’ve also discussed how it differs from the already existing value class and type alias.

As always, the code samples used are available over on GitHub.