Opaque Types in Scala 3

🠔 Back to Blog Index


id: 9e8bf8d5-6c69-475a-a4d5-1ba122dd0559
tags: scala,types

Opaque Types are a new feature offered in Scala 3. They allow you to alias types without any boxing overhead.

♯Table of Contents

♯Demonstration

Let's consider how I've used opaque types in meager-test (simplified).

opaque type Tag = String

object Tag:

  def apply(value: String): Tag = value

  extension (tag: Tag) def toString: String = tag

end Tag

♯What does this achieve?

The REPL is an easy way to demonstrate that opaque types establish a unique type that is distinct from its base type:

scala> object Foo:
     |   opaque type Tag = String
     |   object Tag:
     |     def apply(value: String): Tag = value
     |     extension (tag: Tag) def toString: String = tag
     |
// defined object Foo

scala> val t = Foo.Tag("example")
val t: Foo.Tag = example

scala> val s: String = t
-- [E007] Type Mismatch Error: -----------------------------------------
1 |val s: String = t
  |                ^
  |                Found:    (t : Foo.Tag)
  |                Required: String

scala> t.toString
val res0: String = example

♯Why do I care?

I'm fond of using specific types to represent data. This both improves readability and prevents the wrong values from being used at compile time. Opaque types allow me to do this without runtime overhead. The gist of the idea is to be strict about how data is defined.

This example:

final case class Test(
  permanentId: String,
  name: String
)

def readTestResult(permanentId: String): F[Option[TestResult]]
def searchTests(name: String): F[List[Test]]

Becomes:

final case class Test(
  permanentId: Test.PermanentId,
  name: Test.Name
)

object Test:
  opaque type PermanentId = String
  object PermanentId:
    def apply(value: String): PermanentId = value

  opaque type Name = String
  object Name:
    def apply(value: String): Name = value

def readTestResult(permanentId: Test.PermanentId): F[Option[TestResult]]
def searchTests(name: Test.Name): F[List[Test]]

In the first version, we're essentially dealing with two strings. There is nothing enforcing that a passed-in string is actually a permanent identifier or test name. The parameter names help someone reading the code understand what is supposed to go there - a loose suggestion.

The second version has no ambiguity. You cannot pass an arbitrary string to the Test data type or the functions. Each parameter has a distinct type. The readTestResult function cannot be called with a name or some other arbitrary string.

Another benefit is natural protection against parameter ordering issues which can happen by accident or during refactoring exercises:

val pid = "foo"
val name = "bar"

val test = Test(name, pid)

This contrived example shows how a "name" can be used in place of a "permanent identifier" (and the other way around) if the types are String.

It's worth mentioning that leveraging parameter names is another way to help address the same problem:

val pid = "foo"
val name = "bar"

val test = Test(
  name = name, 
  permanentId = pid
)

But parameter names are (a) not always used and (b) do not prevent data of the wrong type from being used. I like using named parameters, but in general I prefer enforcement by type over enforcement by name.

♯Conclusion

Opaque types are a new, small feature that I see as a massive quality of life improvement for Scala. They enable additional strong typing guarantees without overhead - while you could wrap types in Scala 2, it came with a boxing fee. I use and enjoy patterns supported by opaque types and leverage them heavily in my projects.