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

The example shows the common shapes:

  • Elem[X] reduces to Char for a String, to the element type for an Array and for any other Iterable. Cases are tried from the top, and a String is not an Iterable in Scala, so the String case is the only one that applies. Elem[Nil.type] reduces to Nothing, because Nil is an Iterable[Nothing].

  • Leaf[X] is recursive: it keeps unwrapping nested sequences until it reaches a non-sequence. The case _ => X fallback returns the scrutinee itself.

  • Widen[X <: Int | String] restricts the scrutinee to Int | String with an ordinary type bound. Match types have no default keyword; a final case _ => plays that role.

  • Fact[N <: Int] <: Int uses the operators from scala.compiletime.ops.int (* and - on singleton types) to compute a factorial in the type checker. constValue[Fact[5]] turns the singleton type 120 back into a value, and summon[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:

  1. If the scrutinee S has no values at all (Nothing, or an empty intersection such as String & Int), do not reduce.

  2. Take the cases from the top. If S is a subtype of the pattern Pi (with the type captures instantiated as tightly as possible), reduce to Ti.

  3. Otherwise try to prove that S and Pi are 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.

flowchart TD start(["Reduce S match { P1 => T1 ... Pn => Tn }"]) --> empty{"Is S empty (Nothing, or String & Int)?"} empty -- yes --> stuck(["Stuck: the match type stays unreduced"]) empty -- no --> next{"Is there a case Pi left?"} next -- no --> noCase(["No case applies: the type stays unreduced"]) next -- yes --> sub{"Is S a subtype of Pi, with the captures instantiated?"} sub -- yes --> done(["Reduce to Ti"]) sub -- no --> disj{"Is S provably disjoint from Pi?"} disj -- yes --> advance["Move on to case Pi+1"] advance --> next disj -- no --> stuck

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 final class cannot be extended, and a sealed type has a known set of children;

  • constant (singleton) types with different values do not overlap, and neither do singleton paths to different object declarations 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
...

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

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 case clauses 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 S type 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]] and h *: t are 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

ops.int

+, -, *, /, %, <, >, <=, >=, <<, >>, >>>, ^, BitwiseAnd, BitwiseOr, Min, Max, Abs, Negate, NumberOfLeadingZeros, ToLong, ToFloat, ToDouble, and S, the successor, which can also be used in patterns (case S[n]). ToString is deprecated

ops.long, ops.float, ops.double

The same arithmetic, comparison and bit operators for Long (with ToInt, ToFloat, ToDouble), and the arithmetic ones for Float and Double

ops.boolean

!, &&, ||, ^

ops.string

+, <, >, <=, >=, Length, Substring, CharAt, Matches

ops.any

==, !=, IsConst, ToString (the current replacement for int.ToString)

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] = 9 compile.

  • 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 Aux pattern (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 switch patterns (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 when works 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 determine t, 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 inline method 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.