Scala 3's Type System, Part 4: Opaque Types

scalatype-systems

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 type must be declared inside an object, 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.