Union & Intersection Types

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). Union and intersection types exist since Scala 3.0 and behave the same on the 3.9 LTS line (3.9.0, 2026-09-03). The one change that matters for upgrades is the old compound-type syntax A with B, which has warned since 3.4 and is an error in 3.10 (see A with B is gone).

Scala 3 types form a lattice, and unions and intersections are the two operators that build new types from old ones. A union A | B is the set of values that belong to A or to B. An intersection A & B is the set of values that belong to both. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails, where a place to sleep is a hut or a shelter and a piece of equipment can be both waterproof and insulated.

Venn view: a union is the whole of both circles and an intersection is only the overlap

Union types

A union type is written with a vertical bar. The compiler accepts any value of either alternative where the union is expected, and a method that takes a union needs no overloads and no common supertype.

//> using scala 3.10.0
package trailhead

case class Hut(name: String)
case class Shelter(name: String, beds: Int)

def describe(place: Hut | Shelter): String = place match
  case Hut(n)        => s"hut $n"
  case Shelter(n, b) => s"shelter $n with $b beds"

def parse(s: String): Int | String =
  s.toIntOption.getOrElse(s"not a number: $s")

@main def run(): Unit =
  println(describe(Hut("Refugio")))
  println(describe(Shelter("Col", 6)))
  println(parse("42"))
  println(parse("x"))
  summon[(Hut | Shelter) =:= (Shelter | Hut)]
  summon[Hut <:< (Hut | Shelter)]
  val dup: Int | Int = 3
  println(dup)
hut Refugio
shelter Col with 6 beds
42
not a number: x
3

The three summon and Int | Int lines state the algebra of unions:

  • | is commutative, Hut | Shelter and Shelter | Hut are the same type.

  • Each alternative is a subtype of the union (Hut <: Hut | Shelter), and the union is the least type with that property.

  • A union of a type with itself is the type: Int | Int is just Int.

Unions are not tagged. At run time the value of parse("42") is a plain Integer, and nothing records which side of the union it came from. The only way to find out is to test the value, which is exactly what a match does.

Using a union: match, do not call

The members of a union are the members that are common to the join of its alternatives, and for unrelated classes that is only what AnyRef has. Even when both alternatives declare a method of the same name and type, the compiler does not look through the union:

//> using scala 3.10.0
package trailhead

trait Hut:
  def name: String
  def beds: Int
trait Shelter:
  def name: String
  def roof: Boolean
class Both(val name: String) extends Hut, Shelter:
  def beds = 4
  def roof = true

def nameOf(x: Hut | Shelter): String = x.name
[error] ./a1.scala:14:40
[error] value name is not a member of trailhead.Hut | trailhead.Shelter
[error] def nameOf(x: Hut | Shelter): String = x.name
[error]                                        ^^^^^^

The fix is a type test, case h: Hut => h.name; case s: Shelter => s.name, or an intersection if the intention was "both a hut and a shelter" (see Intersection types). When the alternatives are case class`es, `sealed types or enum cases, a match on the union is checked for exhaustivity:

//> using scala 3.10.0
package trailhead

case class Hut(name: String)
case class Shelter(name: String)

def nameOf(x: Hut | Shelter): String = x match
  case h: Hut => h.name

@main def run(): Unit = println(nameOf(Hut("A")))
[warn] ./x1.scala:7:40
[warn] match may not be exhaustive.
[warn]
[warn] It would fail on pattern case: trailhead.Shelter(_)
[warn] def nameOf(x: Hut | Shelter): String = x match
[warn]                                        ^

The program still ran and printed A. See Pattern Matching for type patterns and erasure, and Type Hierarchy & Matchable for the supertypes that every union of ordinary values is a subtype of.

Inference and widening of unions

The compiler never invents a union unless that is the most precise answer it is allowed to give. The reference states the rule: a union type is assigned to an expression only if it is given explicitly, or if the common supertype of all alternatives is transparent. Otherwise the type is widened to the least common supertype that is not a union.

//> using scala 3.10.0
package trailhead

trait Place
case class Hut(name: String) extends Place
case class Shelter(name: String, beds: Int) extends Place

transparent trait Named
case class Peak(name: String) extends Named
case class Pass(name: String) extends Named

case class Lake(name: String)
case class Pond(name: String)

@main def run(): Unit =
  val widened = if true then Hut("A") else Shelter("B", 2)
  val explicit: Hut | Shelter = if true then Hut("A") else Shelter("B", 2)
  val transp = if true then Peak("P") else Pass("Q")
  val plain = if true then Lake("L") else Pond("M")
  val mixedList = List(1, "two")
  val e1: Int = widened
  val e2: Int = explicit
  val e3: Int = transp
  val e4: Int = plain
  val e5: Int = mixedList
  var counter = if true then 1 else "x"
  val e6: Int = counter

The program deliberately fails to compile so that the compiler reports the inferred type of each value. The six Found: lines of the error, in order, are:

Found:    (widened : trailhead.Place)
Found:    (explicit : trailhead.Hut | trailhead.Shelter)
Found:    (transp : trailhead.Peak | trailhead.Pass)
Found:    (plain : trailhead.Lake | trailhead.Pond)
Found:    (mixedList : List[Int | String])
Found:    (counter : Int | String)

Reading the table row by row:

  • widened is a Place, the common parent of Hut and Shelter, because the trait Place is an ordinary one. The information that it must be a hut or a shelter is lost, and a later match on a Place is no longer exhaustive unless Place is sealed.

  • explicit keeps the union because the programmer wrote it. Annotating the type is the way to ask for the precise type.

  • transp keeps the union without an annotation because Named is a transparent trait: it does not take part in the widening. The reference documents transparent as the way to opt a supertrait out of the widening.

  • plain also keeps the union. Lake and Pond have no parent but Object, and Object is among the classes the reference lists as assumed transparent. This is what makes the common case, a plain case class pair, infer a union.

  • mixedList and counter show that primitives get a union too: Int and String have no common parent other than Any and Matchable, which the compiler treats like Object, so the union is kept. A var gets the union as well.

Transparent traits and classes are described in Scala 3 Reference: Transparent Traits and Classes. Inference in general is covered in Type Inference.

Unions versus ADTs, Either and enums

A union is the cheapest way to say "one of these types" when the alternatives already exist, but it is a different tool from an algebraic data type, with different trade-offs:

Question Union A | B enum / sealed trait / Either[A, B]

Is the choice tagged at run time?

No. The value is an A or a B and nothing more.

Yes. Each case is a class or an object, and Left(3) differs from Right(3).

Can the alternatives be the same type?

They collapse: Int | Int is Int.

Either[Int, Int] keeps two sides, so Left(3) == Right(3) is false.

Must the alternatives be declared together?

No. Any existing types, including library types, can be joined ad hoc.

Cases of a sealed hierarchy or enum are declared by the author of the ADT.

Can you give the choice methods and a name?

Not directly. A union has no members of its own.

Yes. The ADT can carry methods, companions and a derives clause.

Is the result compositional (map, flatMap)?

No. A union has none of its own operations.

Either, Option and Try come with combinators. Unions need explicit match.

Where is it the better fit?

Parameters that accept several unrelated types, error values (Int | Exception), JSON-like inputs, returning T | Null.

Domain modelling, where the cases are part of the model. See Algebraic Data Types & GADTs and Enums.

Verified here with an enum and Either[Int, Int]:

//> using scala 3.10.0
package trailhead

@main def run(): Unit =
  val e1: Either[Int, Int] = Left(3)
  val e2: Either[Int, Int] = Right(3)
  println(e1 == e2)
  val u1: Int | Int = 3
  println(u1)
  val xs: List[Int | String] = List(1, "a", 2)
  println(xs.collect { case i: Int => i })
  val parsed: Int | Exception = "x".toIntOption.getOrElse(IllegalArgumentException("bad"))
  println(parsed)
false
3
List(1, 2)
java.lang.IllegalArgumentException: bad

The collect call is how a mixed list is filtered back to one of its alternatives. The Scala 3 Book makes the same comparison between the two ways of modelling alternatives in Scala 3 Book: Describing alternatives. For the Kotlin and Java counterparts of the ADT side see Kotlin Sealed Classes and Enums and Java Records and Sealed Classes.

Erasure and overloading

Because the JVM has no unions, a union in a method signature is erased to the least upper bound of its alternatives, and two methods whose unions erase to the same type clash:

//> using scala 3.10.0
package trailhead

object Log:
  def write(x: Int | String): Unit = println("A")
  def write(x: Int | Boolean): Unit = println("B")
[error] ./i4.scala:6:7
[error] Conflicting definitions:
[error] def write(x: Int | String): Unit in object Log at line 5 and
[error] def write(x: Int | Boolean): Unit in object Log at line 6
[error] have the same type (x: Object): Unit after erasure.
[error]
[error] Consider adding a @targetName annotation to one of the conflicting definitions
[error] for disambiguation.
[error]   def write(x: Int | Boolean): Unit = println("B")
[error]       ^

Adding @scala.annotation.targetName("writeFlag") to one overload fixes it (checked: Log.write("a") prints A and Log.write(true) prints B), see Scala 3 Reference: The @targetName annotation.

Unions and null

With the compiler option -Yexplicit-nulls the type Null is no longer a subtype of every reference type, and a value that may be null is a union, String | Null. The compiler then refuses to use the union where a plain String is expected:

//> using scala 3.10.0
//> using option -Yexplicit-nulls
package trailhead

def lookup(key: String): String | Null = if key == "a" then "alpha" else null

@main def run(): Unit =
  val v = lookup("b")
  println(v)
  val s: String = lookup("a")
[error] ./x2.scala:10:19
[error] Found:    String | Null
[error] Required: String
[error]   val s: String = lookup("a")
[error]                   ^^^^^^^^^^^

Explicit nulls is an experimental feature, see Scala 3 Reference: Explicit Nulls. The null-safety story with Option is on Option & Null Safety.

Intersection types

A & B has every member of A and of B. A value has the type if it is an A and also a B. Typically this comes from a class that mixes in two traits, and the intersection is how a method requires both capabilities without inventing a named subtype.

//> using scala 3.10.0
package trailhead

trait Waterproof:
  def rating: Int
trait Insulated:
  def warmth: Int

class Parka(val rating: Int, val warmth: Int) extends Waterproof, Insulated

def pack(x: Waterproof & Insulated): String =
  s"waterproof ${x.rating}, warmth ${x.warmth}"

def pack2(x: Insulated & Waterproof): String = pack(x)

@main def run(): Unit =
  val p = Parka(3, 5)
  println(pack(p))
  println(pack2(p))
  val w: Waterproof = p
  w match
    case both: (Waterproof & Insulated) => println("both")
    case _                              => println("only waterproof")
  val only: Waterproof = new Waterproof { def rating = 1 }
  only match
    case both: (Waterproof & Insulated) => println("both")
    case _                              => println("only waterproof")
waterproof 3, warmth 5
waterproof 3, warmth 5
both
only waterproof

The properties shown, which the reference spells out:

  • & is commutative, Waterproof & Insulated and Insulated & Waterproof are the same type, and pack2 calls pack without a conversion.

  • The members of an intersection are all the members of both sides.

  • An intersection can be used as a bound: def score[T <: Waterproof & Insulated](items: List[T]), the Scala version of Java’s <T extends A & B>.

  • In a type pattern the intersection must be wrapped in parentheses, case x: (A & B) =>. Without them the 3.10 parser stops at the &: '=>' expected, but identifier found. (The 3.9 parser reports the same.) The 3.10 release notes call this out as the form the migration rewrite adds for you.

When members have the same name

If both sides define a member with the same name, the member of the intersection has the intersection of the two types. The reference example uses two traits that each return their own list of children:

//> using scala 3.10.0
package trailhead

trait A:
  def children: List[A]
trait B:
  def children: List[B]
class C extends A, B:
  def children: List[A & B] = Nil

@main def run(): Unit =
  val x: A & B = C()
  val ys: List[A & B] = x.children
  println(ys.size)
0

The type of children in A & B is List[A] & List[B], which simplifies to List[A & B] because List is covariant (see Variance). Nobody has to implement that type: it only describes what a value must provide. A class that inherits both traits is the one that has to give a children method of the right type, as C does.

Two unrelated types such as Int & String are legal, and have no values, but they are not reduced to Nothing:

[error] ./a2.scala:16:20
[error] Found:    (impossible : Int & String)
[error] Required: Nothing

(For this check the program contained val impossible: Int & String = ??? followed by val n: Nothing = impossible.)

Intersections versus mixins

A trait list (class Parka extends Waterproof, Insulated) builds a class with its own linearization, see Traits & Mixins. An intersection type is just the type of such a value and has no run-time identity of its own. Two practical consequences: you can write the requirement of a parameter without declaring a combined trait, and the same value can satisfy many different intersections.

A with B is gone

Scala 2 wrote the compound type with the keyword with: def pack(x: Waterproof with Insulated). Scala 3 replaced it with & and deprecated the old form in 3.4. The behaviour was checked on three lines with the same source:

//> using scala 3.10.0
package trailhead

trait Waterproof:
  def rating: Int
trait Insulated:
  def warmth: Int

def pack(x: Waterproof with Insulated): Int = x.rating
[error] ./w1.scala:9:24
[error] with as a type operator has been deprecated; use & instead
[error] This construct can be rewritten automatically under -rewrite -source 3.4-migration.
[error] def pack(x: Waterproof with Insulated): Int = x.rating
[error]                        ^^^^
Scala version Behaviour for Waterproof with Insulated in a type

3.3.8 (LTS)

Compiles with no warning. (The warning started in 3.4.)

3.9.0 (LTS)

Compiles with a deprecation warning, with as a type operator has been deprecated; use & instead.

3.10.0

Compile error, with the same message.

The 3.9.0 and 3.10.0 messages are identical. Only the severity changed. The fix is mechanical, and the compiler can apply it: compile on 3.10 with -rewrite -source 3.4-migration and every A with B type becomes A & B (checked: the file above was patched to def pack(x: Waterproof & Insulated): Int = x.rating). Scala CLI users pass //> using options -rewrite -source:3.4-migration.

with is still valid as the separator between parents in a class declaration, class Parka extends Waterproof with Insulated, which compiled on 3.10.0 without a message. Only its use as a type operator was removed. The comma form class Parka extends Waterproof, Insulated is the modern spelling; see Traits & Mixins.

Comparison with other languages

  • Java has no union types for general values. The closest forms are the multi-catch catch (A | B e), where the exception variable has the join type, and the intersection bounds <T extends A & B>. Scala generalizes both: the & and the | work anywhere a type is expected.

  • Kotlin has neither unions nor intersections as written types, and models alternatives with sealed classes and Any; see Kotlin Sealed Classes and Enums.

  • TypeScript has both A | B and A & B. Its unions are also untagged, and narrowing replaces match.

  • Scala 2 has only the compound type A with B, with no union type; Either and sealed traits carried that load.

Where the books are outdated

  • Material written for Scala 2 uses A with B for compound types. It does not compile on 3.10, see A with B is gone.

  • Examples that use Either where an untagged union would do predate Scala 3 or deliberately model the tag; the choice is discussed in Unions versus ADTs, Either and enums.

Scala 3.9 LTS versus 3.10

  • A with B as a type operator warns on 3.9 LTS and is an error on 3.10. Everything else on this page behaves the same on 3.9.0 and 3.10.0 for the compile-and-run examples that were repeated on both.

  • The intersection pattern syntax case x: (A & B) with parentheses is required on both lines.

  • The 3.3 LTS line accepts A with B silently, so code that must compile on 3.3 and 3.10 should write &, which both accept.