Learn through the super-clean Baeldung Pro experience:
>> Membership and Baeldung Pro.
No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.
Last updated: March 18, 2024
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.
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.
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“.
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 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.
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.
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.
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.