Opaque Type Aliases

Reading Time: 3 minutes

Introduction

Scala is a statically typed programming language. This means the compiler determines the type of a variable at compile time.

A type alias is usually used to simplify declaration for complex types, such as parameterized types or function types.

Opaque type aliases do not allow access to their underlying type outside of the file in which they are defined. Opaque type alias is a feature that helps to implement domain-specific models in a better way.

Domain Modelling

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

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

Opaque Type Alias

The opaque type alias is introduced in Scala 3 to provide type abstraction without any overhead.

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 CartoonShow 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.

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)

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)

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 CartoonShow entity to use these safe methods:

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

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

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.

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

Conclusion

In this blog, 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.

Reference

https://docs.scala-lang.org/scala3/book/types-opaque-types.html

Written by 

Meenakshi Goyal is a Software Consultant and started her career in an environment and organization where her skills are challenged each day, resulting in ample learning and growth opportunities. Proficient in Scala, Akka, Akka HTTP , JAVA. Passionate about implementing and launching new projects. Ability to translate business requirements into technical solutions. Her hobbies are traveling and dancing.