Skip to content
AgileLogics
6 min read

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.