Scala 3's Type System, Part 3: Union and Intersection Types
Two traits both declare a children member. In Scala 2, whether x.children
handed you a List[A] or a List[B] depended on the order you happened to
write A with B. Scala 3 replaces that with a genuine set-theoretic
intersection, adds its dual, and along the way gives you an error channel that
allocates nothing.
🔗with was never an intersection
Set intersection is commutative: A ∩ B and B ∩ A are the same set. Scala 2’s
with was not. It was a linearization operator borrowed from the mixin rules,
and the last trait in the list won member lookups.
// scala-check: skip
// Scala 2
trait A { def children: List[A] }
trait B { def children: List[B] }
def f(x: A with B): List[B] = x.children // ok: member type is List[B]
def g(x: B with A): List[B] = x.children // error: member type is List[A]
f and g take what any reasonable person would call the same type, and only
one of them compiles. Nothing about the value changed — a class implementing
both traits has exactly one children method — but the type you wrote it down
with decided which declaration the compiler consulted.
That is not merely inconvenient. Anything that manipulates types generically
(type class instances, abstract type members, higher-kinded machinery) can build
A with B in one place and B with A in another, and the two stop being
interchangeable.
🔗& intersects members too
Scala 3’s A & B is commutative and associative, and — this is the part that
matters — the type of a member of A & B is the intersection of its member
types in A and B.
trait A:
def children: List[A]
trait B:
def children: List[B]
class C extends A, B:
def children: List[A & B] = Nil
val x: A & B = C()
val ys: List[A & B] = x.children
// `&` is commutative, so these are the same type, not merely compatible ones
val proof = summon[(A & B) =:= (B & A)]
Ask the compiler for the type of x.children and it says List[A & B], from
either spelling. No linearization, no order sensitivity. List[A] & List[B]
reduces to List[A & B] because List is covariant; for an invariant
constructor it would stay an unreduced intersection, which is correct — there is
no single element type that satisfies both.
The everyday use is as a requirement, not a mixin. A & B reads as “give me
something that is both”:
trait Logging:
def log(msg: String): Unit
trait Metrics:
def incr(name: String): Unit
def handle(request: String)(using env: Logging & Metrics): Unit =
env.log(s"handling $request")
env.incr("requests.total")
object Prod extends Logging, Metrics:
def log(msg: String): Unit = println(msg)
def incr(name: String): Unit = ()
given prodEnv: (Logging & Metrics) = Prod
@main def run(): Unit = handle("GET /orders")
A & Bis a type, not a class. You cannotnew (A & B)— you need a concrete class that extends both. And&never fails to typecheck:Int & Stringis a perfectly legal type. It just has no values.
🔗Unions: an error channel with no wrapper
Now the dual. The pain that motivates | is error modeling. Either[E, A] has
room for exactly one E, so as soon as a function can fail in three unrelated
ways you reach for one of three bad options: nest the eithers, invent a sealed
trait that every error must extend, or widen E to something useless like
Throwable.
The sealed-trait approach is the usual choice, and it lies:
sealed trait AppError
final case class BadStatus(code: Int) extends AppError
final case class MalformedBody(detail: String) extends AppError
final case class MissingField(name: String) extends AppError
// Signature says "can fail three ways". It can only fail two.
def fetch(url: String): Either[AppError, String] = Left(BadStatus(503))
Every caller of fetch has to handle MissingField, which fetch cannot
produce. The hierarchy also forces you to own all three error types — you can’t
put someone else’s exception class into your sealed trait. (Sealed hierarchies
are still the right default for closed domains; see
Part 2. The problem here is specifically
that the set of possible failures varies per function.)
Union types drop both constraints. The members need no common supertype and no declared relationship at all:
final case class BadStatus(code: Int)
final case class MalformedBody(detail: String)
final case class MissingField(name: String)
type FetchError = BadStatus | MalformedBody
type DecodeError = MissingField | MalformedBody
final case class Response(status: Int, body: String)
final case class Order(id: String, total: Long)
def fetch(url: String): Either[FetchError, Response] =
Left(BadStatus(503))
def decode(body: String): Either[DecodeError, Order] =
Left(MissingField("total"))
def loadOrder(url: String): Either[FetchError | DecodeError, Order] =
for
res <- fetch(url)
order <- decode(res.body)
yield order
// Unions flatten and deduplicate: MalformedBody appears once, not twice
val flattened =
summon[(FetchError | DecodeError) =:= (BadStatus | MalformedBody | MissingField)]
loadOrder’s error type is computed, not declared, and it is exactly right.
Either is covariant in its left type, so the for comprehension widens each
step’s error into the union without a single wrapper allocation. At runtime a
BadStatus is just a BadStatus; | is entirely erased.
Pattern matching works as you’d expect, and exhaustivity checking follows:
final case class BadStatus(code: Int)
final case class MalformedBody(detail: String)
final case class MissingField(name: String)
def report(e: BadStatus | MalformedBody | MissingField): String = e match
case BadStatus(c) => s"upstream returned $c"
case MalformedBody(d) => s"could not read body: $d"
case MissingField(n) => s"missing field: $n"
Delete the last case and the compiler says It would fail on pattern case: MissingField(_). Two caveats. It is a warning, so it is silent unless you
build with -Werror. And the check is only as good as erasure allows —
List[Int] | List[String] cannot be discriminated at runtime, so a union whose
members erase to the same class buys you nothing in a match.
🔗The gotcha: inference computes a least upper bound
This is the one that trips everybody. Ascribe a union and it behaves. Let the compiler infer it and the union often evaporates:
// scala-check: expect-error
final case class Timeout(ms: Long) extends Exception
final case class Malformed(detail: String) extends Exception
def describe(e: Timeout | Malformed): String = e match
case Timeout(ms) => s"timed out after $ms ms"
case Malformed(d) => s"malformed: $d"
val b: Boolean = true
val err = if b then Timeout(500) else Malformed("bad json")
val msg = describe(err)-- [E007] Type Mismatch Error -----------------------------------------
12 |val msg = describe(err)
| ^^^
| Found: (err : Exception)
| Required: Timeout | Malformed
The inferred type of err is Exception. The compiler computed the least upper
bound of the two branches, and because both extend Exception — as error types
usually do — it found a real common supertype and stopped there. The union was
never formed.
What makes this feel arbitrary is that it sometimes works. Two case classes with no interesting common ancestor do infer as a union:
final case class UserName(name: String)
final case class Password(hash: String)
val b: Boolean = true
// inferred as UserName | Password — there is no useful common supertype
val id = if b then UserName("eve") else Password("2a-xyz")
def lookup(x: UserName | Password): String = x match
case UserName(n) => n
case Password(h) => h
val found = lookup(id)
So the rule is not “unions are never inferred”; it is “inference takes the least
upper bound, and a union only survives when there is nothing better to
generalize to.” The moment your error types share a trait — or extend
Exception — the union collapses.
The fix is to ascribe, and the ergonomic place to do it is the declared return type of a function, where you wanted the documentation anyway:
final case class Timeout(ms: Long) extends Exception
final case class Malformed(detail: String) extends Exception
type LoadError = Timeout | Malformed
// The ascription lives here, once, and propagates to every call site
def load(b: Boolean): Either[LoadError, String] =
if b then Left(Timeout(500)) else Left(Malformed("bad json"))
val err: LoadError = Timeout(500)
Define a type alias for each union you care about and this stops being a daily
irritation. Bare val x = if ... then ... else ... is where it bites.
🔗Explicit nulls
Null is a type like any other, which means String | Null is expressible. The
-Yexplicit-nulls compiler flag makes that the only way to express
nullability: Null stops being a subtype of every reference type, so a plain
String is guaranteed non-null.
// scala-check: skip
// compile with -Yexplicit-nulls
val bad: String = null // error: Found Null, Required String
val ok: String | Null = null // fine
def lookup(key: String): String | Null = System.getenv(key)
def port(): Int =
val raw = lookup("PORT")
if raw != null then raw.toInt else 8080 // flow typing narrows raw to String
def host(): String =
lookup("HOST").nn // assert non-null, throws if wrong
Flow typing is what makes it usable: after raw != null, the compiler narrows
raw to String for the rest of the branch, with no cast and no Option
allocation.
Flow typing only applies to local
vals. A field is not narrowed, because nothing stops another thread reassigning it between the test and the use.
// scala-check: skip
// compile with -Yexplicit-nulls
class Config(val host: String | Null)
def show(c: Config): String =
if c.host != null then c.host.toUpperCase else "unset"
// ^^^^^^ error: Found String | Null, Required String
def showFixed(c: Config): String =
val h = c.host
if h != null then h.toUpperCase else "unset"
The second sharp edge is Java interop: every Java signature gets nullified
deeply, so "a,b".split(",") has type Array[String | Null] | Null. Calling
into Java under this flag is a wall of .nn until you wrap it.
That cost is why the flag is opt-in and still marked experimental. It’s a good fit for a new codebase with little Java interop, and a slog to retrofit.
🔗Next up
Unions and intersections give you precision over types you already have. The
next tool is the opposite: making a String into a type the compiler will
distinguish, without paying for a wrapper at runtime. That’s
Part 4: Opaque Types.