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.