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 | Bunions need no common supertype; match to narrowA & Bvalues satisfy both types
Watch out for
- Expecting the compiler to infer
Int | Stringfor an if/else: annotate the expected union type.