Match 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). Match types have been part of Scala 3 since 3.0, and the specification they follow changed in 3.4 (SIP-56). The runnable examples that do not use a 3.10-only feature were also compiled and run on the 3.9 LTS (3.9.0, 2026-09-03) with the same result; the places where the 3.3 LTS (3.3.8) behaves differently are named in the text.
A match type is a type whose value is chosen by pattern matching on another type. Where a match expression looks at a
value and picks an expression, a match type looks at a type and picks a type. The compiler does the matching while it
type checks, so the result is known before the program runs and costs nothing at run time. Match types let a method
signature say the element type of whatever you pass in, a Long for an Int and a String for a String, or a
tuple with its first two components swapped, and they are the foundation of the tuple operations in the standard
library. Java has no type-level computation of this kind (generics are not computed from other types), and Kotlin has none
either; the closest relatives are TypeScript conditional types and Haskell closed type families, compared at the end of the
page. For the term-level match see Pattern Matching; for the
singleton and literal types used below see Type Hierarchy & Matchable.
Defining a match type
A match type is a type alias whose right-hand side is X match followed by case lines of the form pattern => result.
Both sides of the arrow are types. A lowercase name in a pattern, such as t in Array[t], is a type capture (or type
variable): it binds to whatever type sits in that position of the scrutinee, and the result may use it. The result of a
case is a type, so => Char and => 3 * 4 are both valid right-hand sides.
//> using scala 3.10.0
package trailhead
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
type Leaf[X] = X match
case Seq[t] => Leaf[t]
case _ => X
def firstChar(s: String): Elem[String] = s.charAt(0)
def firstOf[T](xs: List[T]): Elem[List[T]] = xs.head
def leafDemo(): Unit =
val a: Leaf[Int] = 3
val b: Leaf[List[List[String]]] = "deep"
println(s"$a $b")
type Widen[X <: Int | String] = X match
case Int => Long
case String => CharSequence
import scala.compiletime.ops.int.*
import scala.compiletime.constValue
type Fact[N <: Int] <: Int = N match
case 0 => 1
case _ => N * Fact[N - 1]
@main def run(): Unit =
println(firstChar("Pico"))
println(firstOf(List(10, 20)))
leafDemo()
val w: Widen[Int] = 5L
println(w)
println(constValue[Fact[5]])
println(constValue[2 + 3])
summon[Fact[4] =:= 24]
P
10
3 deep
5
120
5
Source: Scala 3 Reference: Match Types
The example shows the common shapes:
-
Elem[X]reduces toCharfor aString, to the element type for anArrayand for any otherIterable. Cases are tried from the top, and aStringis not anIterablein Scala, so theStringcase is the only one that applies.Elem[Nil.type]reduces toNothing, becauseNilis anIterable[Nothing]. -
Leaf[X]is recursive: it keeps unwrapping nested sequences until it reaches a non-sequence. Thecase _ => Xfallback returns the scrutinee itself. -
Widen[X <: Int | String]restricts the scrutinee toInt | Stringwith an ordinary type bound. Match types have nodefaultkeyword; a finalcase _ =>plays that role. -
Fact[N <: Int] <: Intuses the operators fromscala.compiletime.ops.int(*and-on singleton types) to compute a factorial in the type checker.constValue[Fact[5]]turns the singleton type120back into a value, andsummon[Fact[4] =:= 24]proves the equality at compile time.
The <: Int before the = in Fact is an upper bound on the match type. Every instance of Fact[N] is known to be an
Int, even if it cannot be reduced, which the compiler needs in order to accept N * Fact[N - 1] (the * type requires
Int operands). The same trick keeps recursive tuple types well formed, as shown later.
A match type is not a type pattern at run time. The case lines never run; they are only consulted while the compiler
computes the type, and type captures behave like the type variables of a term-level pattern (case x: Array[t]).
Reduction
The compiler reduces a match type S match { P1 => T1 ... Pn => Tn } to the first Ti that applies. The reference
specifies the rule through the semantics of the term-level match: the match type reduces to Ti if and only if the
corresponding match expression returns a value of type Ti for every value of type S. The algorithm is:
-
If the scrutinee
Shas no values at all (Nothing, or an empty intersection such asString & Int), do not reduce. -
Take the cases from the top. If
Sis a subtype of the patternPi(with the type captures instantiated as tightly as possible), reduce toTi. -
Otherwise try to prove that
SandPiare disjoint, that is, that no value can belong to both. If that succeeds, move to the next case. If it fails, stop: the match type is stuck and does not reduce.
The diagram is the reduction of the reference: two outcomes reduce, and the one in the middle is the place where programs usually go wrong, because "does not match" and "provably cannot match" are different questions. A scrutinee that is merely unknown, such as a type parameter, is neither, so the reduction stops at the first case instead of skipping it.
Stuck match types
The compiler can only skip a case when it can prove the scrutinee is disjoint from the pattern. With an abstract scrutinee, it cannot prove anything, so the whole type stays unreduced and no value of the result type can be written:
//> using scala 3.10.0
type Elem[X] = X match
case String => Char
case Iterable[t] => t
def wrong[X](x: X): Elem[X] = 'a'
@main def run(): Unit = println(wrong("s"))
./d.scala:6:31
Found: ('a' : Char)
Required: Elem[X]
Note: a match type could not be fully reduced:
trying to reduce Elem[X]
failed since selector X
does not match case String => Char
and cannot be shown to be disjoint from it either.
Therefore, reduction cannot advance to the remaining case
case Iterable[t] => t
def wrong[X](x: X): Elem[X] = 'a'
^^^
The error is honest about the reason: X does not match String and cannot be shown to be disjoint from it, so the
compiler may not look at the remaining cases. The usual remedies are to use a term-level match in dependent mode or an
inline match, both shown below, or to apply the match type only to concrete types.
The same stuck behavior appears with ordinary traits. Two unrelated traits are not disjoint, because a class can extend both of them, so the second case is unreachable for the reducer:
//> using scala 3.10.0
package trailhead
trait Rope
trait Cable
type Gear[X] = X match
case Rope => "rope"
case Cable => "cable"
val g: Gear[Cable] = "cable"
./q_err.scala:11:22
Found: ("cable" : String)
Required: trailhead.Gear[trailhead.Cable]
Note: a match type could not be fully reduced:
trying to reduce trailhead.Gear[trailhead.Cable]
failed since selector trailhead.Cable
does not match case trailhead.Rope => ("rope" : String)
and cannot be shown to be disjoint from it either.
Therefore, reduction cannot advance to the remaining case
case trailhead.Cable => ("cable" : String)
val g: Gear[Cable] = "cable"
^^^^^^^
Making the classes final (or sealed, with known children) gives the compiler the disjointness proof it needs:
//> using scala 3.10.0
package trailhead
final class Rope
final class Cable
type Gear[X] = X match
case Rope => "rope"
case Cable => "cable"
@main def run(): Unit =
val g: Gear[Cable] = "cable"
println(g)
cable
Disjointness proofs rely on the following facts about Scala types, in the words of the reference and the SIP:
-
classes have single inheritance, so two classes with no inheritance relation and no shared child cannot overlap;
-
a
finalclass cannot be extended, and asealedtype has a known set of children; -
constant (singleton) types with different values do not overlap, and neither do singleton paths to different
objectdeclarations or enum cases; -
two parameterized types are disjoint when they share a supertype with provably disjoint arguments in an invariant position.
Recursion and termination
Match types may call themselves, which is how the type-level Leaf, Fact and Concat work. The price is that a
definition can loop forever. Reduction is linked to subtyping, so the compiler already has a cycle detector, and it turns
the resulting stack overflow into a compile error:
//> using scala 3.10.0
package trailhead
type Forever[X] = X match
case Int => Forever[X]
val v: Forever[Int] = ???
./f.scala:7:8
Recursion limit exceeded.
Maybe there is an illegal cyclic reference?
If that's not the case, you could try to increase the fuel and stack size: https://docs.scala-lang.org/overviews/compiler-options/compiling-deeply-nested-code.html
For the unprocessed stack overflow trace, compile with -Xno-enrich-error-messages.
A recurring operation is (inner to outer):
match cases for Int
...
Source: Scala 3 Reference: Termination
The first lines of the message are shown. The error continues with the trace of the operation that kept recurring, and the
exact text of the trace varies between compiler versions. The advice in the message (raise the fuel and the stack size,
see Compiling deeply nested code in the references) applies only to legitimately deep types; for an accidental loop the
fix is in the definition. A recursive match type needs a case that stops (a base case such as EmptyTuple or 0) and a
recursive call on a strictly smaller type.
Recursion with scala.compiletime.ops fails in the same way when the arithmetic runs away. The definition below never
reaches its base case, because each step adds one instead of subtracting one, and Loop[1] also ends in "Recursion limit
exceeded", with a trace of ever-growing sums.
//> using scala 3.10.0
package trailhead
import scala.compiletime.ops.int.*
type Loop[N <: Int] = N match
case 0 => 0
case _ => Loop[N + 1]
val v: Loop[1] = ???
Source: Scala 3 Reference: Termination
Bounded and recursive tuple types
A recursive match type over a tuple needs an upper bound to compose with the tuple operators, because *: demands a
Tuple on its right. The declared bound <: Tuple provides it even when the match cannot be reduced yet:
//> using scala 3.10.0
package trailhead
import scala.compiletime.ops.int.*
type Concat[A <: Tuple, B <: Tuple] <: Tuple = A match
case EmptyTuple => B
case h *: t => h *: Concat[t, B]
type Rev[T <: Tuple] <: Tuple = T match
case EmptyTuple => EmptyTuple
case h *: t => Tuple.Concat[Rev[t], h *: EmptyTuple]
type Len[T <: Tuple] <: Int = T match
case EmptyTuple => 0
case _ *: t => S[Len[t]]
@main def run(): Unit =
val j: Concat[(Int, String), (Boolean, Double)] = (1, "a", true, 2.0)
val r: Rev[(Int, String, Boolean)] = (true, "a", 1)
println(j)
println(r)
println(scala.compiletime.constValue[Len[(Int, String, Boolean)]])
(1,a,true,2.0)
(true,a,1)
3
Concat takes the head h off A and rebuilds it in front of Concat[t, B]; EmptyTuple ends the recursion. Rev
reuses the library’s Tuple.Concat to append each head at the end. Len uses scala.compiletime.ops.int.S, the successor type, to count. The standard library defines Tuple.Concat,
Tuple.Size, Tuple.Map, Tuple.Zip, Tuple.Reverse and others the same way, so most of the time you can compose them
instead of writing your own.
Dependently typed methods
A match type can be the result type of a method. The method body is then a term-level match over the same cases, and
the compiler checks each branch against the matching result type instead of the whole match type:
//> using scala 3.10.0
package trailhead
type LeafElem[X] = X match
case String => Char
case Array[t] => LeafElem[t]
case Iterable[t] => LeafElem[t]
case AnyVal => X
def leafElem[X](x: X): LeafElem[X] = x match
case x: String => x.charAt(0)
case x: Array[t] => leafElem(x(0))
case x: Iterable[t] => leafElem(x.head)
case x: AnyVal => x
@main def run(): Unit =
println(leafElem(List(List("Pico", "Ridge"))))
println(leafElem(Array(Array(3, 4))))
println(leafElem(2.5))
P
3
2.5
This special typing mode of the match expression is used only when all of the following hold:
-
the
caseclauses have no guards (if); -
the type of the scrutinee is a subtype of the scrutinee type of the match type;
-
the expression and the match type have the same number of cases, in the same order;
-
every pattern of the expression is a typed pattern, and its type is equivalent (
=:=) to the corresponding type pattern of the match type.
If any condition fails, the method body has to produce a value of the unreduced match type, which cannot be done in
general. The last branch in the example, x of type AnyVal, is checked against X, but that does not constrain X to
be an AnyVal; the compiler only knows that this branch is selected when x has that type.
When the types are known at the call site, inline is a lighter route. An inline match on a type is resolved at
compile time, so the result type can be a match type and each branch is checked only for the type that is selected:
//> using scala 3.10.0
package trailhead
import scala.compiletime.{constValue, erasedValue}
type Key[X] = X match
case Int => Long
case String => String
inline def widen[X](x: X): Key[X] = inline x match
case i: Int => i.toLong
case s: String => s.trim
inline def depth[T <: Tuple]: Int = inline erasedValue[T] match
case _: EmptyTuple => 0
case _: (h *: t) => 1 + depth[t]
@main def run(): Unit =
val k: Long = widen(7)
val name: String = widen(" Pico ")
println(s"$k [$name]")
println(depth[(Int, String, Boolean)])
val sizes: Tuple.Size[(Int, String, Boolean)] = 3
val lists: Tuple.Map[(Int, String), List] = (List(1), List("x"))
println(s"$sizes $lists")
7 [Pico]
3
3 (List(1),List(x))
erasedValue[T] pretends to return a value of type T and may only appear in code that is removed by inlining; that is
what lets depth select a case by type alone. Tuple.Size[(Int, String, Boolean)] and Tuple.Map[(Int, String), List]
are the library’s own match types: the first reduces to the literal type 3, the second to
(List[Int], List[String]). See Inline for inline, erasedValue and
constValue in depth.
The SIP-56 specification (Scala 3.4)
Until Scala 3.3 the reduction of match types was whatever the compiler did. Matching a scrutinee against a pattern with
captures asked the type checker to "try and make it so", and the answer depended on type inference, which is not
specified. That is a problem specific to match types: a reduction is recomputed from the TASTy files of libraries, so two
compiler versions that disagree can produce broken subtyping, broken overriding or an AbstractMethodError from
inconsistent erasure. SIP-56 (Proper Specification for Match Types, by Sébastien Doeraene) replaced the implementation
with a specification that is based only on baseType computations and subtype tests on fully defined types. It ships in
Scala 3.4 (released 2024-02-29) and therefore in both the 3.9 LTS and 3.10. The 3.3 LTS keeps the old algorithm.
The practical consequences of the specification are:
-
Legal patterns. A pattern is legal if it has no captures, or if it is a class (or abstract, or the
Stype constructor) applied to captures, other legal patterns, or types without captures, with the rule that a nested pattern with captures must sit in a covariant position.Cov[Inv[t]]andh *: tare legal;Inv[Cov[t]]is not. -
Matching with bounds. A capture binds to the scrutinee’s exact argument; if the scrutinee only has bounds there (
Box[? <: Int]) the capture is not determined and the case fails as "not specific" instead of guessing a type. -
Disjointness is defined by a relation on "simple types" (classes, literals, enum values, unions and intersections), as sketched earlier. If a scrutinee reduces, every subtype of it reduces to the same case.
-
Reduction always tests both "matches" and "provably disjoint". If both hold, the scrutinee is empty (for example
Nothing) and the match type does not reduce.
An illegal case is a compile error under Scala 3.4 and later:
//> using scala 3.10.0
package trailhead
class Inv[A]
class Cov[+A]
type Bad[X] = X match
case Inv[Cov[t]] => t
type Good[X] = X match
case Cov[Inv[t]] => t
./illegal.scala:7:15
The match type contains an illegal case:
case trailhead.Inv[trailhead.Cov[t]] => t
The pattern contains an unaccounted type parameter `t`.
(this error can be ignored for now with `-source:3.3`)
Compiling the same file with -source:3.3 accepts the illegal case and reduces it with the old, unspecified algorithm,
without a warning:
//> using scala 3.10.0
//> using option -source:3.3
package trailhead
class Inv[A]
class Cov[+A]
type Bad[X] = X match
case Inv[Cov[t]] => t
type Good[X] = X match
case Cov[Inv[t]] => t
@main def run(): Unit = println("compiled under -source:3.3")
compiled under -source:3.3
The SIP reports that, out of 4,783 Scala 3 libraries on Maven Central at the time, 49 defined match types with 779 cases
in all, and only 8 of those cases were rejected; they had a workaround. Do not rely on -source:3.3 for new code: the
fallback may be removed in a future release.
Captures that cannot be determined produce a specific message. A Box[? <: Int] fixes only the upper bound of t:
//> using scala 3.10.0
package trailhead
class Box[T](val value: T)
type Unbox[X] = X match
case Box[t] => t
val exact: Unbox[Box[String]] = "Pico"
val loose: Unbox[Box[? <: Int]] = 1
./bounds.scala:10:35
Found: (1 : Int)
Required: trailhead.Unbox[trailhead.Box[? <: Int]]
Note: a match type could not be fully reduced:
trying to reduce trailhead.Unbox[trailhead.Box[? <: Int]]
failed since selector trailhead.Box[? <: Int]
does not uniquely determine parameter t in
case trailhead.Box[t] => t
The computed bounds for the parameter are:
t <: Int
The 3.3 LTS rejects the same program as well, but with the vaguer "cannot be shown to be disjoint" text, so the line that names the capture and its computed bounds is the visible improvement of the specification for this example:
./bounds33.scala:10:35
Found: (1 : Int)
Required: trailhead.Unbox[trailhead.Box[? <: Int]]
Note: a match type could not be fully reduced:
trying to reduce trailhead.Unbox[trailhead.Box[? <: Int]]
failed since selector trailhead.Box[? <: Int]
does not match case trailhead.Box[t] => t
and cannot be shown to be disjoint from it either.
Compile-time operations
scala.compiletime.ops contains match-type-friendly operators over singleton (literal) types. They are ordinary type
aliases implemented by the compiler, written infix, and they follow the usual operator precedence, so 1 + 2 * 3 as a type
is 7. There is one package per left operand type; to use + from two packages in the same scope, import the packages
and qualify (int.+, string.+).
| Package | Operators |
|---|---|
|
|
|
The same arithmetic, comparison and bit operators for |
|
|
|
|
|
|
The following program uses several of them, including a bounded range check that makes an out-of-range literal a compile error:
//> using scala 3.10.0
package trailhead
import scala.compiletime.constValue
import scala.compiletime.ops.{any, boolean, int, long, string}
// a stage count that must stay within a sensible range, checked by the compiler
type Stages[N <: Int] = int.<=[N, 14] match
case true => N
case false => Nothing
type Countdown[N <: Int] <: Tuple = N match
case 0 => EmptyTuple
case int.S[n] => N *: Countdown[n]
type Label = string.+["Stage ", any.ToString[3]]
@main def run(): Unit =
val ok: Stages[9] = 9
println(ok)
val c: Countdown[3] = (3, 2, 1)
println(c)
println(constValue[Label])
println(constValue[any.==[3, 3]])
println(constValue[string.Substring["Pico de Orizaba", 0, 4]])
println(constValue[int.%[17, 5]])
println(constValue[int.Negate[4]])
println(constValue[long.+[2L, 3L]])
println(constValue[boolean.![false]])
println(constValue[string.Matches["trail-12", "[a-z]+-[0-9]+"]])
println(constValue[int.ToLong[7]])
9
(3,2,1)
Stage 3
true
Pico
2
-4
5
true
true
7
Passing an out-of-range value, val bad: Stages[20] = 20, fails because Stages[20] reduces to Nothing:
Found: (20 : Int)
Required: Nothing
Singleton arithmetic is evaluated for literals only. A type parameter that is not a literal stays unreduced, and the
operators on it stay stuck, as with any other match type. int.ToString is deprecated since 3.2.0 in favor of
any.ToString, and the compiler says so when you use the old name. To get a run-time value out of a computed type use
constValue (an error if the type is not a constant) or constValueOpt.
Subtyping rules
The reference gives three rules that tie match types to subtyping:
-
Two match types are subtypes when their scrutinees and patterns are equal and each body is a subtype; the subtype may have more cases at the end, but no reordering is allowed.
-
A match type and its reduct are subtypes of each other. This is what lets
val ok: Stages[9] = 9compile. -
A match type is a subtype of its declared upper bound, whether or not it reduces.
All type positions of a match type (scrutinee, patterns and bodies) are invariant.
Comparison with other languages
-
Scala 2. There are no match types. Type-level computation used implicits that encode functions, typically the
Auxpattern (trait Elem[X] { type Out }with instances found by implicit search) and libraries such as Shapeless for tuples and naturals. The Scala 3 form is shorter and is checked by the compiler directly, but implicit-based encodings still compile on Scala 3. -
Java. Generics cannot be computed from other type arguments, and
switchpatterns (JEP 441) work on values, not on types. Libraries emulate it with overloads or with generated code. -
Kotlin. No equivalent; type parameters are fixed at the declaration and
whenworks on values. -
TypeScript. Conditional types (
T extends string ? Char : ...) are the closest form. They reduce only when the scrutinee and pattern are ground and they distribute over unions, while Scala match types work with abstract types and do not distribute. -
Haskell. Closed type families are the relative of match types. A type family reduction unifies, which tightens the constraint; a match type reduction does not, which mirrors the difference between local type inference in Scala and global inference in Haskell.
Scala 3.9 LTS versus 3.10
There is nothing in this page that differs between 3.9.0 and 3.10.0: the examples compile and give the same output on
both. Compared with the 3.3 LTS the differences are the SIP-56 rules of 3.4 (illegal cases are rejected, with
-source:3.3 as an escape hatch) and the more precise diagnostics for captures. Code written for the 3.3 LTS that uses
only legal patterns keeps its meaning. A library that ships match types and cross-builds on 3.3 and later must avoid the
patterns that are illegal under 3.4.
Pitfalls
-
Abstract scrutinees. A match type on a type parameter is stuck unless a case matches the whole bound. Do not expect
Elem[X]to reduce inside a generic method. -
Order and overlap. Cases are tried from the top. A broad case before a narrow one hides the narrow one, and a pair of non-final traits is never disjoint.
-
Infinite recursion. Always have a base case and a strictly smaller argument.
-
Captures with bounds.
Box[? <: Int]does not determinet, so the case does not apply. -
No run-time representation. A match type exists only at compile time. It cannot be used to decide something at run time; for that, match on a value (or use
inline, which resolves the choice before the program runs). -
Compile times. Deep recursion through the compile-time operators (a long unary count, say) is slow; prefer an
inlinemethod for loops over values.
Where the books are outdated
Material written for the first Scala 3 releases presents match types as a feature whose reduction is "whatever the
compiler decides". Since 3.4 it is specified (SIP-56), illegal patterns are rejected, and the messages name the capture
that cannot be determined. Older text also uses int.ToString and the Nat/S[...] encodings of Scala 2; use the
operators of scala.compiletime.ops and Tuple.Size instead. The examples that target the 3.3 LTS in older articles
still run unchanged on 3.9 and 3.10 when their patterns are legal.