Opaque, union and intersection types

Scala · Expert · 15 min

These Scala 3 features model domains precisely with no runtime overhead.

An opaque type alias creates a new type that is the underlying type at runtime but a distinct type at compile time. Inside its defining scope the alias is transparent; outside, only the API you export is visible:

object ids:
  opaque type UserId = Long
  object UserId { def apply(n: Long): UserId = n }

UserId and Long can't be mixed up, yet no wrapper object is allocated, unlike a case class wrapper (and unlike Scala 2 value classes, which box in generic contexts). Add operations with extension methods in the same scope, and use the companion to validate (def from(n: Long): Option[UserId]).

Union types A | B say "a value of type A or B". They're unboxed and need no common supertype: def parse(s: String): Int | ParseError. Pattern matching narrows them. Unions are also how Scala types literal alternatives: type Dir = "N" | "S" | "E" | "W".

Intersection types A & B are values that are both: def save(x: Serializable & Validated). They replace Scala 2's compound with types and are commutative (A & B equals B & A).

When inferring the type of a val or def, the compiler widens a union to a common non-union supertype: val x = if c then 1 else "a" doesn't get Int | String unless you annotate it, so write the union type where you want one. Opaque types can't be distinguished at runtime, so matching case id: UserId is an unchecked type test.

Example

object units:
  opaque type Meters = Double
  opaque type Seconds = Double
  def meters(d: Double): Meters = d
  def seconds(d: Double): Seconds = d
  extension (m: Meters) def /(s: Seconds): Double = m / s  // inside: Doubles
import units.*
val speed = meters(100) / seconds(9.58)
println(f"$speed%.2f m/s")                  // 10.44 m/s
// meters(1) / 2.0                          // error: found Double, required Seconds
enum ParseError { case Empty, Bad }
def parse(s: String): Int | ParseError =
  if s.isEmpty then ParseError.Empty else s.toIntOption.getOrElse(ParseError.Bad)

for s <- List("42", "", "x") do
  parse(s) match
    case n: Int        => println(s"int $n")
    case e: ParseError => println(s"error $e")   // int 42 / error Empty / error Bad
type Dir = "N" | "S"
val d: Dir = "N"                            // "W" would not compile
trait Named { def name: String }
trait Aged { def age: Int }
def card(p: Named & Aged) = s"${p.name} (${p.age})"
println(card(new Named with Aged { val name = "Ana"; val age = 31 }))  // Ana (31)

Remember

  • Opaque types: distinct at compile time, the raw type at runtime
  • Add operations to opaque types with extensions in their scope
  • A | B unions need no common supertype; match to narrow
  • A & B values satisfy both types

Watch out for

  • Expecting the compiler to infer Int | String for an if/else: annotate the expected union type.

Check yourself

  1. What is the runtime representation of opaque type UserId = Long?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

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

Builds on

Unlocks

Nothing builds on this yet.