A function maps values to values. A match type maps types to types, and it does so by pattern matching, just like a match expression does on values. Scala 3 added match types so libraries can state precisely how a result type depends on an argument type: the element type of a collection, the type of a tuple after appending, the return type of a method that behaves differently for strings and arrays.
They are easy to write and surprisingly easy to get stuck on. A match type only reduces when the compiler can prove which case applies, and the rules for that proof were tightened and properly specified in Scala 3.4 by SIP-56. This page explains match types from the ground up: the syntax, the reduction algorithm and its four possible outcomes, the disjointness proof behind them, which patterns are legal, recursion, how a term-level match can be typed with a match type, and how to debug a match type that will not reduce. The wider toolkit, including compile-time arithmetic and given-driven proofs, is in Scala type-level programming, in depth.
Anatomy of a match type
Here is the standard first example, defined with a type parameter X:
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => tThe part before match is the scrutinee, here X. Each case has a pattern on the left and a result type on the right. A lowercase name inside a pattern, such as t, is a type capture: it binds to whatever type appears in that position, exactly as a lowercase variable binds in a value pattern. So Elem[String] is Char, Elem[Array[Int]] is Int and Elem[List[Double]] is Double.
You can bound the result with an upper bound, written type Elem[X] <: Any = X match .... The bound matters when the match type cannot reduce: the compiler then knows at least that the result is a subtype of the bound, so code using it can still call the bound's members. Bounds are also how recursive definitions over numbers or tuples stay well typed, for example type Size[T <: Tuple] <: Int = ....
Two rules shape everything else. Cases are tried in order and are never reordered, so a more specific case must come before a more general one. And every position in a match type, scrutinee, patterns and bodies, is treated as invariant: Elem[List[Int]] and Elem[List[Any]] are related only if their reductions are.
How reduction works
Reduction is the compiler evaluating a match type for a given scrutinee. Since Scala 3.4, SIP-56 specifies it without relying on type inference. For each case in order, the compiler first computes the captures, then checks that the scrutinee is a subtype of the pattern with those captures substituted. Separately, it asks whether the scrutinee and the pattern are provably disjoint, meaning no value can belong to both. The combination decides the outcome:
| Matches the pattern? | Provably disjoint? | Outcome |
|---|---|---|
| Yes | No | Reduce to this case body |
| Yes | Yes | No reduction: the scrutinee is uninhabited |
| No | Yes | Skip this case and try the next one |
| No | No | Stuck: the type stays unreduced |
The last row is the key to understanding match types. Failing to match a case is not enough to move on, because the scrutinee might be a type that the compiler cannot see fully, such as a type parameter, and at a later call site it could turn out to match. Moving on safely needs a proof that no value could ever belong to both.
Disjointness proofs use facts the type system can rely on: two classes are disjoint if neither is a subclass of the other and they cannot share a subclass, which is guaranteed by single class inheritance or by one of them being final; distinct literal types such as 1 and 2 are disjoint; sealed hierarchies and enum cases are disjoint where their children are; and unions and intersections are handled by breaking them into parts. What can never be proven is anything about an abstract type: a type parameter T with no useful bound, an abstract type member, or an open trait that a future class might extend together with another type.
Worked example: three scrutinees
Trace three scrutinees through Elem to see each outcome.
Elem[List[Int]]. Case 1:List[Int]does not matchString.Stringis a final class andListis not a subclass of it, so they are disjoint; skip. Case 2:Arrayis also final and unrelated; skip. Case 3:List[Int]is a subtype ofIterable[t]witht = Int; reduce toInt.Elem[Seq[Int]].Seqis a trait, butStringandArrayare final classes that do not extend it, so no class can be both; the first two cases are skipped and the result isInt.Elem[T]inside a generic methoddef f[T](x: T).Tdoes not matchString, and nothing provesTis notString; the type is stuck. The compiler leavesElem[T]as it is, soval c: Char = ...fails with a type mismatch.
The fix for the third case is almost never to change the match type. Reduce later, at a call site where the type is concrete, for example by making the method inline so its body is checked after the argument type is known, or pass a type class instance that already carries the answer. You can check reductions with the compiler itself: summon[Elem[List[Int]] =:= Int] compiles only if the reduction happens, which makes a cheap unit test for type-level code.
Legal patterns since Scala 3.4
Before Scala 3.4, the compiler accepted almost any pattern and reduced it with an unspecified algorithm based on type inference. Results could change between compiler versions. SIP-56 defined a subset of patterns as legal and gave them precise rules. From Scala 3.4, illegal patterns are rejected with a compile error; compiling with -source:3.3 accepts them again and reduces them with the old behaviour. That flag is a migration aid, not a fix.
| Pattern | Legal? | Why |
|---|---|---|
Int, List[Int], Array[String] | Yes | A type without captures; plain subtype test |
List[t], Either[s, t], h *: t | Yes | Class type constructor with direct captures |
S[n] from compiletime.ops.int | Yes | Specifically allowed for natural-number recursion |
Cov[Inv[t]] with Cov covariant | Yes | A nested capture is allowed in covariant position |
Inv[Cov[t]] with Inv invariant | No | Capture nested two levels below a non-covariant constructor |
| A type lambda as the pattern | No | Not allowed directly |
| An alias whose parameter bounds do not cover all instantiations | No | The compiler cannot rewrite it to a legal form |
In practice, write patterns as a class constructor applied to captures or concrete types, keep each capture appearing once, and avoid hiding captures inside invariant generic types. If a pattern needs to look two levels deep through an invariant type, split it into two match types, each looking one level deep.
Recursive match types
Match types may refer to themselves, which is how they iterate over tuples and type-level numbers. Two examples from tuple processing:
import scala.compiletime.ops.int.+
type Size[T <: Tuple] <: Int = T match
case EmptyTuple => 0
case h *: t => 1 + Size[t]
type Flatten[X] = X match
case Array[t] => Flatten[t]
case Iterable[t] => Flatten[t]
case _ => X
val n: Size[(Int, String, Boolean)] = 3 // compiles: Size reduces to 3
val f: Flatten[List[Array[Int]]] = 42 // Flatten reduces to IntSize uses the cons pattern h *: t and compile-time addition. Each step reduces the tuple by one element until EmptyTuple. Recursive match types are checked for cycles during subtyping, and when reduction overflows the compiler's stack, Scala 3 turns that into a type error with a trace of the reduction steps. The documentation suggests increasing the compiler's thread stack with -Xss if a legitimate deep reduction needs it; a real infinite recursion will fail regardless. How tuples are represented, and the standard operations such as Tuple.Concat and Tuple.Map that are themselves match types, is covered in Scala tuples, in depth.
Notice the wildcard case in Flatten. A catch-all is fine at the end, but it does not rescue a stuck type: if an earlier case cannot be skipped because disjointness is unknown, reduction stops before reaching the wildcard.
Typing a match expression with a match type
A match type describes a result type; the value still has to be computed by ordinary code. The compiler can check that a match expression produces the right type in each branch if the expression and the match type line up exactly. The Scala 3 reference gives four conditions: the match has no guards; the scrutinee's type is a subtype of the match type's scrutinee; the expression and the match type have the same number of cases; and every pattern is a typed pattern whose type is equivalent to the corresponding pattern in the match type.
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 => xEach branch is typed against the matching case body, so returning x.charAt(0) is checked against Char. Add a guard such as case x: String if x.nonEmpty, or reorder the branches, and the method no longer type checks, because the shapes no longer correspond. When that happens, the usual alternatives are an inline method with an inline match, which is resolved per call site, or a type class with one given instance per case. Metaprogramming tools for the harder cases are in Scala 3 metaprogramming.
Debugging a match type that will not reduce
When code that uses a match type fails to compile, read the error for a note saying that a match type could not be fully reduced. The compiler then lists, case by case, why it could not skip a case: typically that the scrutinee does not match the pattern and cannot be shown to be disjoint from it. Compiling with -explain adds more context. Work through it in this order:
- Find the scrutinee the compiler actually saw. If it is a type parameter or an abstract member, the problem is where you reduce, not how you wrote the match type.
- Check that the earlier cases can be proven disjoint. Making a class final, a trait sealed, or replacing an open trait in a pattern with a concrete class often unblocks reduction.
- Check case order. A general case such as
Iterable[t]beforeList[t]will capture lists first. - If an upgrade to Scala 3.4 or later rejects a pattern as illegal, restructure the pattern rather than adding
-source:3.3permanently. - Pin the expected reductions in tests with
summon[A =:= B]so a compiler upgrade that changes behaviour fails in CI rather than in a downstream user.
| Symptom | Cause | Fix |
|---|---|---|
| Type mismatch showing the unreduced match type | Abstract scrutinee; stuck | Reduce at a concrete call site, use inline, or a type class |
| Wrong case chosen | A broader case comes first | Reorder cases from specific to general |
| Illegal pattern error after upgrading | Pattern outside the SIP-56 subset | Rewrite with direct captures; split nested matches |
| Stack overflow during compilation | Deep or infinite recursion | Check the base case; raise -Xss only for legitimately deep reductions |
| Term match no longer typed as the match type | Guard, reordering, or pattern mismatch | Make the shapes identical, or switch to inline match |
| Very slow compilation | Large recursive reductions repeated at many call sites | Reduce once into an alias, or move the computation to a macro or code generation |
When to use a match type
Use a match type when the result type genuinely depends on the shape of an input type and a few clear cases describe it: element types, tuple transformations, type-level counters. Prefer a type class with givens, described in Scala 3 contextual abstraction, when users should be able to add new cases from outside your library, because match types are closed and cannot be extended. Prefer plain overloading or a sealed trait of results when the dependency is simple. And keep match types out of public APIs when your users are not comfortable reading the error messages they produce; a stuck type in someone else's code is a hard thing to diagnose.
What to do next
- Confirm your compiler is Scala 3.4 or later, and search the build for -source:3.3; each use hides a pattern that needs rewriting.
- Write Elem from this page in a scratch file and add summon checks for String, List and Seq; then make a generic method and watch it get stuck.
- For each match type in your code, list which cases depend on disjointness proofs and check those types are final, sealed or concrete.
- Order every match type from specific to general and add a test for the case most likely to be shadowed.
- Where a method returns a match type, make its body a match with exactly the same cases and no guards, or switch it to inline.
- Add summon-based reduction tests to CI so a compiler upgrade cannot silently change results.
- Decide for each public match type whether users need to extend it; if so, replace it with a type class.