Scala 3's Type System, Part 8: Match Types and Compile-Time Computation
Some functions have a return type that depends on the type of the argument in a way no type parameter can express: List[Int] should give back Int, String should give back Char, Array[T] should give back T. Overloading doesn’t scale and Any throws the type away. Match types let a type pattern-match on another type and compute the answer.
🔗The problem: a return type that isn’t a parameter
You want firstElem. Three rules: a String yields a Char, an Array[T] yields a T, an Iterable[T] yields a T. Try it with a type parameter and there’s nothing to write — the result isn’t X, and it isn’t a fixed type either. So you reach for Any:
// scala-check: expect-error
def firstElem(x: Any): Any = x match
case s: String => s.charAt(0)
case a: Array[?] => a(0)
case i: Iterable[?] => i.head
val c: Char = firstElem("hello") // Found: Any, Required: Char
Every caller now casts. Overloading is no better: firstElem(xs: Array[T]): T and firstElem(xs: Iterable[T]): T erase to the same signature in some combinations, and the set of overloads grows with every new case. Neither approach lets the compiler derive the result type from the input type.
🔗Match types
A match type is a type-level match. The selector is a type; the cases are types; the result is a type:
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
val a: Elem[String] = 'h'
val b: Elem[Array[Int]] = 1
val c: Elem[List[Order]] = Order("ord-1")
final case class Order(id: String)
Elem[String] reduces to Char, Elem[Array[Int]] to Int, Elem[List[Order]] to Order. Lowercase pattern variables (t) bind, exactly as in a term-level match. Cases are tried in order, so put specific ones first.
Reduction happens whenever the compiler needs it, and it’s transparent: Elem[String] and Char are the same type, interchangeable everywhere.
🔗Making the term level follow
A match type on its own only describes the result. To actually return the right value you need the implementation to branch the same way, which is what inline gives you — the body is expanded at the call site, where X is known:
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
transparent inline def firstElem[X](x: X): Elem[X] =
inline x match
case s: String => s.charAt(0)
case a: Array[t] => a(0)
case i: Iterable[t] => i.head
@main def run(): Unit =
val c: Char = firstElem("hello")
val i: Int = firstElem(Array(1, 2, 3))
val s: String = firstElem(List("a", "b"))
println((c, i, s))
No casts at the call site, and val c: Char typechecks. inline x match is not a runtime match — it’s resolved during expansion, and unreachable branches are dropped from the generated code.
🔗inline vs transparent inline
A plain inline def keeps its declared return type. transparent inline lets the compiler use the actual type of the expanded body, which is usually more specific:
inline def zero(): Any = 0
transparent inline def zeroT(): Any = 0
val a: Int = zeroT() // fine: expansion has type Int// scala-check: expect-error
inline def zero(): Any = 0
val b: Int = zero() // Found: Any, Required: Int
Rule of thumb: if the useful type is only known after expansion, you need transparent. The cost is that the signature no longer tells callers what they get — you have to read the body — and changing the body can silently change every caller’s inferred type.
🔗Where match types earn their keep: tuples
Scala 3 tuples are heterogeneous lists built from *: and EmptyTuple, and that structure is exactly what recursive match types are good at. Here is Concat, which is how the standard library’s Tuple.Concat is defined:
type Concat[Xs <: Tuple, Ys <: Tuple] <: Tuple = Xs match
case EmptyTuple => Ys
case x *: xs => x *: Concat[xs, Ys]
@main def run(): Unit =
val joined: Concat[(String, Int), (Boolean, Char)] = ("a", 1, true, 'x')
println(joined)
Note the <: Tuple after the parameter list. That’s an upper bound on the result of the match type, and it lets the compiler know the result is a Tuple even before it can reduce — which matters inside generic code where Xs isn’t concrete yet.
Recursion also works on non-tuple shapes. A Flatten that strips nested List layers:
type Flatten[X] = X match
case List[t] => Flatten[t]
case _ => X
val n: Flatten[List[List[List[Int]]]] = 3🔗Arithmetic in types
scala.compiletime.ops.int gives you +, -, *, <, == and friends as type-level operators on singleton Int types. Combined with recursion you can compute numbers during compilation, and constValue brings the result back down to the term level:
import scala.compiletime.constValue
import scala.compiletime.ops.int.*
type Size[X <: Tuple] <: Int = X match
case EmptyTuple => 0
case _ *: xs => 1 + Size[xs]
inline def sizeOf[X <: Tuple]: Int = constValue[Size[X]]
@main def run(): Unit =
println(sizeOf[(String, Int, Boolean)]) // 3, computed by the compiler
The 3 is a literal in the bytecode. Size reduced to the singleton type 3, and constValue turned that type into the value.
🔗Summoning instances per element
The pattern that makes all of this useful in a library: walk a tuple’s types with erasedValue, resolve a type class instance for each element with summonInline, and recurse. This is the machinery underneath the derivation in Part 6:
import scala.compiletime.{erasedValue, summonInline}
trait Show[A]:
def show(a: A): String
given Show[Int] with
def show(a: Int): String = a.toString
given Show[String] with
def show(a: String): String = s"\"$a\""
inline def showTuple[T <: Tuple](t: T): List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h *: ts) =>
val rest = t.asInstanceOf[h *: ts]
summonInline[Show[h]].show(rest.head) :: showTuple[ts](rest.tail)
@main def run(): Unit =
println(showTuple(("ord-1", 42))) // List("ord-1", 42)
erasedValue[T] produces no runtime value at all — it exists only so inline match has something to scrutinize. summonInline defers the implicit search until expansion, when h is a concrete type. Miss an instance and you get a compile error at the call site, not a runtime failure.
🔗Custom compile errors
compiletime.error aborts compilation with a message you write. Combined with inline if on a literal argument, you can validate constants:
import scala.compiletime.error
inline def port(inline n: Int): Int =
inline if n < 1 || n > 65535 then error("port must be between 1 and 65535")
else n
val http = port(8080)// scala-check: expect-error
import scala.compiletime.error
inline def port(inline n: Int): Int =
inline if n < 1 || n > 65535 then error("port must be between 1 and 65535")
else n
val bad = port(70000)-- Error: BadPort.scala:7:10
7 |val bad = port(70000)
| ^^^^^^^^^^^
| port must be between 1 and 65535
The error points at the call site with your wording. This is a much better experience than a runtime require, and it composes with the opaque-type techniques from Part 4 for validated-at-compile-time wrappers.
🔗Gotchas — and these are real
Reduction failures produce bad errors. When the selector doesn’t match any case, the compiler reports the downstream type mismatch and appends a note:
Found: Elem[Int]
Required: Char
Note: a match type could not be fully reduced:
trying to reduce Elem[Int]
failed since selector Int
matches none of the cases
case String => Char
case Array[t] => t
case Iterable[t] => t
That one is legible. In generic code, where the selector is an abstract type variable rather than Int, the message is a wall of unreduced type applications and the real cause — a missing bound, or a case ordering issue — is not stated anywhere. Debugging usually means instantiating the type parameter by hand until reduction succeeds.
Recursion can run away. A match type whose recursive case doesn’t shrink the selector will exhaust the compiler:
// scala-check: expect-error
type Loop[N <: Int] = N match
case 0 => Int
case _ => Loop[N]
val x: Loop[5] = 1Recursion limit exceeded.
Maybe there is an illegal cyclic reference?
A recurring operation is (inner to outer):
reduce type (5 : Int) match ...
reduce type (5 : Int) match ...
Note what’s missing: any indication of which case failed to make progress. There’s no termination checker — you’re responsible for making every recursive case structurally smaller, and the failure mode is a stack of identical lines. The same shape appears when a legitimate recursion is just deep; a Concat over a 300-element tuple will hit the limit too.
Match types and
inlinerun during typechecking, on every compile, for every call site. Deep recursion over large tuples is measurably slow, and becausetransparent inlineresults feed back into inference, a single change can force re-expansion across a lot of downstream code. If a module’s compile time jumps, heavy inline/match-type usage is the first place to look.
They’re a library-author tool. Match types exist so that Tuple.Concat, Tuple.Map, Mirror-based derivation, and typed-query DSLs can be written without macros. Reaching for them in application code is almost always a mistake: an ordinary sealed trait plus a normal match is clearer, compiles faster, and produces errors a colleague can read. The honest test is whether you’re writing an API that others call with types you can’t enumerate in advance. If you can enumerate them, enumerate them.
🔗Next up
Match types compute types from types. Part 9 goes the other direction — types that depend on values: path-dependent types, singleton literal types like 42 and "admin", Selectable for structural access, and where each one is worth the trouble.