Scala 3's Type System, Part 8: Match Types and Compile-Time Computation

scalatype-systems

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] = 1
Recursion 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 inline run during typechecking, on every compile, for every call site. Deep recursion over large tuples is measurably slow, and because transparent inline results 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.