1. Introduction

Match types are a new feature in Scala 3 and are a fundamental part of the so-called type-level programming. Roughly speaking, type-level programming means manipulating types as opposed to value-level programming, where we deal with and manipulate values.

Match types might be a bit of a niche feature, well-suited when we want to implement methods whose implementation depends on the type(s) of its parameter(s).  Such methods are called dependent methods.

In this tutorial, we’ll see what match types in Scala 3 are and how we can use them to implement dependent methods, comparing the code with an implementation based on Scala 2. Lastly, we’ll take a brief look at the subtyping rules for match types. However, we won’t go too much into the theoretical details.

2. What Are Match Types?

Match types in Scala 3 are similar to the plain old pattern matching, but at the type level.

A match type defines a type member that, when evaluated by the compiler, reduces to a new, possibly different, concrete type. Let’s see an example:

type FirstComponentOf[T] = T match
  case String      => Option[Char]
  case Int         => Int
  case Iterable[t] => Option[t]

In the example above, we declared a new match type, FirstElementOf.  Depending on the type T, the first element has a different meaning. In particular, the first element of an Int is an Int itself, which is the first digit of the number. Similarly, the first element of a String is its first Char. Lastly, the first value of an Iterable[t] is its head. For String and Iterable, we use Option, as strings and iterables can be empty.

Now that we have defined a new match type FirstElementOf, we can initialize some values as usual:

val aNumber: FirstComponentOf[Int] = 2
val aChar: FirstComponentOf[String] = Some('b')
val anItem: FirstComponentOf[Seq[Float]] = Some(3.2f)

Internally, a match type is in the form S match { P1 => T1 … Pn => Tn }, where S, T1, …, Tn are types and P1, …, Pn are type patterns. Scala attempts to reduce S (called scrutinee) to any of the T1, …, Tn types by using one of the P1, …, Pn patterns when evaluating a match type.

2.1. Recursive Match Types

Match types can be part of a recursive definition:

type Node[T] = T match
  case Iterable[t] => Node[t]
  case Array[t]    => Node[t]
  case AnyVal      => T

Since match types can be recursive, there’s nothing preventing us from writing an infinite loop. Luckily, the Scala compiler’s able to detect such situations re-using the same cycle detection algorithm it uses for subtyping.

If we attempted to write an infinite match type definition, we’d get the following error message at compile time: “Recursion limit exceeded. Maybe there is an illegal cyclic reference? If that’s not the case, you could also try to increase the stacksize using the -Xss JVM option“. Internally, the compiler turns stack overflow errors into type errors. This is why the error message also says “If that’s not the case, you could also try to increase the stacksize using the -Xss JVM option“.

3. Dependent Methods

Thanks to match types, we can write a method returning the first element of a given type without any code duplication:

def firstComponentOf[U](elem: U): FirstComponentOf[U] = elem match
  case s: String => if (s.nonEmpty) Some(s.charAt(0)) else Option.empty[Char]
  case i: Int    => i.abs.toString.charAt(0).asDigit
  case it: Iterable[_] => it.headOption

There are a few rules about the implementation of methods returning a match type:

  • the various cases don’t have guards. As a matter of fact, we had to write an if expression instead of using a guard in the first case.
  • the type of the scrutinee in the match expression (that is U above) is a subtype of the type of the scrutinee of the match type (that is T in the match type declaration)
  • the match expression and the match type have the same number of cases
  • the match expression uses only typed patterns. Hence, each case has to be in the form x: X => …, where every given X type must be equivalent to the corresponding type in the match type. Equivalent here means that the two types are mutually subtypes of each other.

The firstComponentOf() method can be called as any other Scala method:

firstComponentOf(-153) shouldEqual 1
firstComponentOf("Baeldung") shouldEqual Some('B')
firstComponentOf("") shouldEqual None
firstComponentOf(Seq(10, 42)) shouldEqual Some(10)
firstComponentOf(Seq.empty[Int]) shouldEqual None

If we attempt to call firstComponentOf() with a type other than String, Int, or Iterable[_] we’ll get a compiler error. For instance, firstComponentOf(1.2f) won’t compile because “Match type reduction failed since selector Float matches none of the cases“.

We can easily fix this by explicitly adding a sort of “default” case:

type FirstComponentOf[T] = T match
  case String      => Option[Char]
  case Int         => Int
  case Iterable[t] => Option[t]
  case Any         => T

def firstComponentOf[U](elem: U): FirstComponentOf[U] = elem match
  case s: String => if (s.nonEmpty) Some(s.charAt(0)) else Option.empty[Char]
  case i: Int    => i.abs.toString.charAt(0).asDigit
  case it: Iterable[_] => it.headOption
  case a: Any          => a

With the fixed definition, the call firstComponentOf(1.2f) will work and return 1.2.

4. Comparison With Scala 2

Match types in Scala 3 solve a very subtle Scala 2 issue related to code duplication. If we wanted to implement the same behavior of FirstComponentOf in Scala 2, we’d have to make intensive use of implicits and type classes.

First, we need to create a generic sealed trait with two members, the Result and the firstComponentOf method.  The latter inputs a parameter of type T (the scrutinee in match types) and returns a value of type Result:

sealed trait FirstComponentOfScala2[-T] {
  type Result
  def firstComponentOf(elem: T): Result
}

Next, instead of using the handy pattern matching syntax, we’ve to rely on implicits to define all the cases supported by our match type wannabe. Such a solution comes with the same type safety guarantees: if the compiler doesn’t find an implicit for a given type, it stops with the error “no implicit argument of type FirstComponentOfScala2[…] was found for parameter T of method firstComponentOf in object FirstComponentOfScala2“. Nonetheless, the code is much more difficult to read than match types, as the different logic implementations are spread all over the file:

object FirstComponentOfScala2 {
  implicit val firstComponentOfString: FirstComponentOfScala2[String] =
    new FirstComponentOfScala2[String] {
      override type Result = Option[Char]

      override def firstComponentOf(elem: String): Option[Char] =
        if (elem.nonEmpty) Some(elem.charAt(0)) else Option.empty[Char]
    }

  implicit val firstComponentOfInt: FirstComponentOfScala2[Int] =
    new FirstComponentOfScala2[Int] {
      override type Result = Int

      override def firstComponentOf(elem: Int): Int =
        elem.abs.toString.charAt(0).asDigit
    }

  implicit def firstComponentOfIterable[U]
    : FirstComponentOfScala2[Iterable[U]] =
    new FirstComponentOfScala2[Iterable[U]] {
      override type Result = Option[U]

      override def firstComponentOf(elem: Iterable[U]): Option[U] =
        elem.headOption
    }

  implicit val firstComponentOfAny: FirstComponentOfScala2[Any] =
    new FirstComponentOfScala2[Any] {
      override type Result = Any

      override def firstComponentOf(elem: Any): Any = elem
    }

  def firstComponentOf[T](elem: T)(implicit
    T: FirstComponentOfScala2[T]
  ): T.Result = T.firstComponentOf(elem)
}

A solution that doesn’t use implicits is also possible. In that case, we’d have to define different methods with different names for each type pattern. However, that would break the DRY (Don’t Repeat Yourself) principle, as we’d end up with methods with pretty similar signatures.

5. Subtyping With Match Types

As with the other types in Scala, there are some rules to establish when a match type’s a subtype of another match type.

Without going too much into the theoretical and the various rules, we can say that S match { P1 => T1 … Pm => Tm } is a subtype of T match { Q1 => U1 … Qn => Un } if and only if the patterns and the scrutinee are equal and if the T1, …, Tm are subtypes of the U1, …, Un, with m >= n.

This makes sense, as it essentially means that, given the same scrutinee and set of type patterns a match type is a subtype of another match type if the former reduces to a subtype of the latter. The subtype might have more type patterns, since m >= n, which is again correct, because that means the subtype is going to have a more specific set of patterns than the supertype.

6. Conclusion

In this article, we explored what match types in Scala 3 are. We analyzed a possible use case, even though it might be an uncommon one. We scratched the surface of the theory behind them. Lastly, we saw when a match type is a subtype of another one.

As always, the source code for the examples can be found over on GitHub.