Opaque, Union, and Intersection Types
Prerequisites: Type classes
Opaque types
An opaque type is a new type that's represented by an existing one at runtime — with zero overhead — but is a different type to the compiler:
object Ids:
opaque type UserId = Long
opaque type OrderId = Long
object UserId:
def apply(value: Long): UserId = value
object OrderId:
def apply(value: Long): OrderId = value
extension (id: UserId) def value: Long = id
import Ids.*
def findUser(id: UserId): String = s"user #${id.value}"
@main def opaqueTypes(): Unit =
val user = UserId(42)
val order = OrderId(42)
println(findUser(user))
// findUser(order) // does not compile: OrderId is not a UserId
// findUser(42L) // does not compile either
user #42
Inside Ids, UserId is a Long; outside, it's a distinct type that
only offers the methods you define. Mixing up two IDs, or a price and a
quantity, becomes a compile error.
object Ids:
opaque type UserId = Long
object UserId:
def apply(value: Long): UserId = value
opaque type OrderId = Long
object OrderId:
def apply(value: Long): OrderId = value
import Ids.*
def findUser(id: UserId): String = "found"
@main def mixUp(): Unit =
println(findUser(OrderId(42)))
-- [E007] Type Mismatch Error: Main.scala:14:26 --------------------------------
14 | println(findUser(OrderId(42)))
| ^^^^^^^^^^^
| Found: Ids.OrderId
| Required: Ids.UserId
|
| longer explanation available when compiling with `-explain`
1 error found
Union types
A | B means "an A or a B":
case class NotFound(id: Int)
case class Forbidden(reason: String)
def load(id: Int): String | NotFound | Forbidden =
if id == 1 then "Ada"
else if id == 2 then Forbidden("private profile")
else NotFound(id)
def render(result: String | NotFound | Forbidden): String = result match
case name: String => s"Hello, $name"
case NotFound(id) => s"No user $id"
case Forbidden(reason) => s"Denied: $reason"
@main def unions(): Unit =
List(1, 2, 3).map(load).map(render).foreach(println)
Hello, Ada
Denied: private profile
No user 3
Union types are handy for ad-hoc alternatives (like a function returning one of a few error types) without defining a sealed hierarchy.
Intersection types
A & B means "both an A and a B":
trait Named:
def name: String
trait Aged:
def age: Int
case class Person(name: String, age: Int) extends Named, Aged
def describe(x: Named & Aged): String = s"${x.name} (${x.age})"
@main def intersections(): Unit =
println(describe(Person("Grace", 45)))
Grace (45)
describe accepts any value with both capabilities, whatever its class.