Scala 3's Type System, Part 3: Union and Intersection Types

scalatype-systems

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 & B is a type, not a class. You cannot new (A & B) — you need a concrete class that extends both. And & never fails to typecheck: Int & String is 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.