Scala 3's Type System, Part 4: Opaque Types
def transfer(from: String, to: String, amount: Long) has three parameters and
every one of them is the wrong kind of interchangeable. Swap the first two and
the money moves the other way, with no compiler complaint and no test failure
until production. Scala 3’s opaque type fixes that without adding a single
object allocation.
🔗Everything is a String
Here’s the shape of the problem. Both of these compile:
def transfer(from: String, to: String, amount: Long): Unit =
println(s"$amount from $from to $to")
@main def run(): Unit =
val checking = "acct_9f2a"
val savings = "acct_71b0"
transfer(savings, checking, 25000L) // meant to go the other way
The types carry no information. String says “some text”, which describes an
account number, a customer name, a currency code and a SQL fragment equally
well. The same problem shows up as Long for both userId and orderId, and
as Double for both meters and seconds.
Before Scala 3 there were three ways to fix this, and each one costs you something.
🔗(a) Type alias — no safety at all
type UserId = String
type OrderId = String
def cancel(user: UserId, order: OrderId): Unit = ()
@main def run(): Unit =
val u: UserId = "u-8814"
val o: OrderId = "o-3120"
cancel(o, u) // compiles: aliases are transparent
A type alias is documentation that the compiler expands away immediately.
UserId is String at every stage after parsing, so there is nothing to
check. It’s a comment with syntax highlighting.
🔗(b) Case class wrapper — safe, but real
// scala-check: expect-error
final case class UserId(value: String)
final case class OrderId(value: String)
def cancel(user: UserId, order: OrderId): Unit = ()
@main def run(): Unit =
cancel(OrderId("o-3120"), UserId("u-8814"))-- [E007] Type Mismatch Error -----------------------------------------
8 | cancel(OrderId("o-3120"), UserId("u-8814"))
| ^^^^^^^^^^^^^^^^^
| Found: OrderId
| Required: UserId
This works. It also creates a real object per ID. In a request handler that’s
noise; in a Map[UserId, Account] with a million entries it’s a million extra
headers on the heap. Array[UserId] becomes an array of pointers instead of an
array of String references, JSON codecs need a hand-written unwrap on both
sides, and every use site is peppered with .value.
🔗(c) AnyVal value class — allocates anyway
final case class UserId(value: String) extends AnyVal
def show(id: UserId): String = id.value
@main def run(): Unit = println(show(UserId("u-8814")))
The promise is that the compiler erases the wrapper and passes the underlying
String directly. It does — in the narrow case above. It does not when the
value class is used as a type argument (List[UserId], Option[UserId]), when
it’s stored in an array, when it’s the scrutinee of a pattern match, when it’s
assigned to an Any, or when it appears in a field of a class. Those are most
of the places IDs actually live, so you get the syntax of a wrapper, the
restrictions of AnyVal (exactly one public val, no auxiliary constructors,
no nesting inside a class), and the allocation anyway — just unpredictably.
🔗opaque type
An opaque type is a type alias that is transparent inside the scope that defines it and completely abstract everywhere else. There is no wrapper class at all; it erases to the underlying representation, always.
object Ids:
opaque type UserId = String
opaque type OrderId = String
object UserId:
def apply(raw: String): UserId = raw
extension (id: UserId) def value: String = id
object OrderId:
def apply(raw: String): OrderId = raw
extension (id: OrderId) def value: String = id
import Ids.*
def cancel(user: UserId, order: OrderId): Unit =
println(s"cancelling ${order.value} for ${user.value}")
@main def run(): Unit =
cancel(UserId("u-8814"), OrderId("o-3120"))
Swap the arguments and you get the same Found: OrderId / Required: UserId
error as the case class version. Try val leak: String = UserId("u-8814")
outside Ids and that fails too — the compiler knows nothing about the
representation out here.
Inside object Ids, UserId and String are interchangeable, which is how
def apply(raw: String): UserId = raw typechecks with no cast. That asymmetry
is the whole design: one small module gets to see through the abstraction so it
can build and destructure values, and nobody else does.
opaque typemust be declared inside anobject,class,trait, or package object — never at the top level of a file. The enclosing scope is the boundary, so there has to be one.
🔗Smart constructors and extension methods
Because construction goes through your code, an opaque type is the natural place
to put validation. Return Either or Option and the invariant becomes a
property of the type: if you’re holding an Email, it parsed.
object Contact:
opaque type Email = String
object Email:
private val Pattern = """^[^@\s]+@[^@\s]+\.[^@\s]+$""".r
def parse(raw: String): Either[String, Email] =
val trimmed = raw.trim.toLowerCase
if Pattern.matches(trimmed) then Right(trimmed)
else Left(s"not a valid email address: $raw")
extension (e: Email)
def value: String = e
def domain: String = e.substring(e.indexOf('@') + 1)
given Ordering[Email] = Ordering.String
import Contact.*
@main def run(): Unit =
Email.parse("Ada@Example.COM") match
case Right(e) => println(s"${e.value} at ${e.domain}")
case Left(err) => println(err)
val addresses = List(Email.parse("b@x.com"), Email.parse("a@x.com"))
.collect { case Right(e) => e }
.sorted
println(addresses.map(_.value))
Note the normalization: parse lowercases and trims, and because it is the only
way in, every Email in the system is normalized. That is a much stronger
guarantee than “we remembered to call .toLowerCase at the boundary”.
Extension methods defined in the opaque type’s companion are found without an
import, so the type gets a real API — e.domain here — while e is still a
bare String at runtime.
🔗Units of measure
The same machinery catches dimension bugs. speed(t, d) and speed(d, t) are
both (Double, Double) to a Scala 2 compiler:
object Units:
opaque type Meters = Double
opaque type Seconds = Double
opaque type MetersPerSecond = Double
object Meters:
def apply(d: Double): Meters = d
extension (m: Meters)
def toDouble: Double = m
def +(other: Meters): Meters = (m: Double) + (other: Double)
object Seconds:
def apply(d: Double): Seconds = d
extension (s: Seconds) def toDouble: Double = s
object MetersPerSecond:
def apply(d: Double): MetersPerSecond = d
extension (v: MetersPerSecond) def toDouble: Double = v
def speed(distance: Meters, elapsed: Seconds): MetersPerSecond =
MetersPerSecond((distance: Double) / (elapsed: Double))
import Units.*
@main def run(): Unit =
val d = Meters(400.0)
val t = Seconds(48.5)
println(speed(d, t).toDouble)
println((d + Meters(100.0)).toDouble)
speed(t, d) is now a compile error. An Array[Meters] is a double[] on the
JVM — the primitive array, not a boxed one — which is the property that makes
this usable in numeric code where a wrapper would be unthinkable.
The ascriptions (distance: Double) inside the module are there for clarity;
within object Units the alias is transparent, so they’re free.
🔗Bounds: leaking on purpose
Total opacity means reimplementing every operation you want. Sometimes that’s the point, and sometimes you just want a distinct type that still behaves like its representation. An upper bound exposes the representation one way:
object People:
opaque type Age <: Int = Int
object Age:
def from(i: Int): Option[Age] =
if i >= 0 && i <= 130 then Some(i) else None
import People.*
@main def run(): Unit =
val a: Age = Age.from(37).getOrElse(sys.error("bad age"))
val asInt: Int = a // ok: Age <: Int
val next: Int = a + 1 // ok, but the result is Int, not Age
println((asInt, next))
You get Int’s entire API for free and Age still can’t be constructed from an
arbitrary Int. The trade-off is that arithmetic escapes the type — a + 1 is
an Int, so the invariant 0 <= age <= 130 is not preserved by operations. Use
bounds when the type is mainly about construction being checked, and full
opacity when the operations matter too.
🔗Gotchas
Opacity is per defining scope, not per file or import. The boundary is the
enclosing object/class/trait, so anything else in that same body sees
straight through — including code you didn’t intend to privilege:
object Ids:
opaque type UserId = String
object UserId:
def apply(raw: String): UserId = raw
// same scope: the alias is transparent, so this compiles
val leaked: UserId = "never went through apply"
Keep the defining object small. If it’s a 900-line object model, every helper
in it can forge values, and the guarantee is gone.
given instances do not transfer. Outside the defining scope UserId is
not String, so nothing implicit that applies to String applies to it:
// scala-check: expect-error
object Ids:
opaque type UserId = String
object UserId:
def apply(raw: String): UserId = raw
import Ids.*
val ids = List(UserId("b"), UserId("a"))
val sorted = ids.sorted // No implicit Ordering defined
Every type class instance you want — Ordering, CanEqual, a JSON codec, a
database column mapping — has to be re-provided in the companion, where it is
usually a one-liner (given Ordering[UserId] = Ordering.String). It’s a real
cost, and worth budgeting for before you opaque-type fifty things.
Part 6 covers making that cheaper.
Erasure still bites at definition sites. Two opaque types over the same representation collide if you overload on them in the same scope:
// scala-check: expect-error
object Ids:
opaque type UserId = String
opaque type OrderId = String
extension (id: UserId) def render: String = s"user:$id"
extension (id: OrderId) def render: String = s"order:$id"Double definition:
def render(id: Ids.UserId): String in object Ids at line 6 and
def render(id: Ids.OrderId): String in object Ids at line 7
have the same type after erasure.
Consider adding a @targetName annotation to one of the conflicting definitions
for disambiguation.
Zero cost means real erasure, and real erasure means JVM signature collisions.
Putting each extension in its own companion object — as in the Ids.scala
example above — sidesteps this, which is a good reason to do it that way from
the start.
No runtime type test. case id: UserId => outside the defining scope is an
unchecked match against String. If you need to discriminate at runtime, an
opaque type is the wrong tool; use an enum
or keep the wrapper.
🔗Next up
Opaque types push work onto type class instances you now have to write by hand.
The next post covers the mechanism those instances are built from — given,
using, and how Scala 3 replaced implicit with something you can actually
reason about: Part 5: Given, Using, and Type Classes.