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] => t

The 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
YesNoReduce to this case body
YesYesNo reduction: the scrutinee is uninhabited
NoYesSkip this case and try the next one
NoNoStuck: the type stays unreduced
Scrutinee X, next case PDoes X match P?captures, then X subtype of PyesnoDisjoint?X and PDisjoint?X and PnoReduceto this bodyyesNo reductionX is emptyyesTry next caseback to topnoStuckstays unreducedMoving past a case needs a proof of disjointness, not just a failed match.Abstract or open types cannot be proven disjoint, which is why they get stuck.
The SIP-56 decision for one case. Skipping a case requires proof that the scrutinee and pattern are disjoint; failing to match is not enough.

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 match String. String is a final class and List is not a subclass of it, so they are disjoint; skip. Case 2: Array is also final and unrelated; skip. Case 3: List[Int] is a subtype of Iterable[t] with t = Int; reduce to Int.
  • Elem[Seq[Int]]. Seq is a trait, but String and Array are final classes that do not extend it, so no class can be both; the first two cases are skipped and the result is Int.
  • Elem[T] inside a generic method def f[T](x: T). T does not match String, and nothing proves T is not String; the type is stuck. The compiler leaves Elem[T] as it is, so val 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.

PatternLegal?Why
Int, List[Int], Array[String]YesA type without captures; plain subtype test
List[t], Either[s, t], h *: tYesClass type constructor with direct captures
S[n] from compiletime.ops.intYesSpecifically allowed for natural-number recursion
Cov[Inv[t]] with Cov covariantYesA nested capture is allowed in covariant position
Inv[Cov[t]] with Inv invariantNoCapture nested two levels below a non-covariant constructor
A type lambda as the patternNoNot allowed directly
An alias whose parameter bounds do not cover all instantiationsNoThe 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 Int

Size 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      => x

Each 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:

  1. 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.
  2. 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.
  3. Check case order. A general case such as Iterable[t] before List[t] will capture lists first.
  4. If an upgrade to Scala 3.4 or later rejects a pattern as illegal, restructure the pattern rather than adding -source:3.3 permanently.
  5. 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.
SymptomCauseFix
Type mismatch showing the unreduced match typeAbstract scrutinee; stuckReduce at a concrete call site, use inline, or a type class
Wrong case chosenA broader case comes firstReorder cases from specific to general
Illegal pattern error after upgradingPattern outside the SIP-56 subsetRewrite with direct captures; split nested matches
Stack overflow during compilationDeep or infinite recursionCheck the base case; raise -Xss only for legitimately deep reductions
Term match no longer typed as the match typeGuard, reordering, or pattern mismatchMake the shapes identical, or switch to inline match
Very slow compilationLarge recursive reductions repeated at many call sitesReduce 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

  1. 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.
  2. 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.
  3. For each match type in your code, list which cases depend on disjointness proofs and check those types are final, sealed or concrete.
  4. Order every match type from specific to general and add a test for the case most likely to be shadowed.
  5. 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.
  6. Add summon-based reduction tests to CI so a compiler upgrade cannot silently change results.
  7. Decide for each public match type whether users need to extend it; if so, replace it with a type class.
Key takeaway: A match type reduces case by case, and it can only move past a case when the compiler proves the scrutinee is disjoint from that case's pattern, so abstract types get stuck. Since Scala 3.4, only the SIP-56 legal patterns are accepted. Order cases from specific to general, reduce where types are concrete, mirror the cases exactly in term-level matches, and pin expected reductions with summon tests.