'Best Practices of Safe Pattern Matching in a Scala Application' post illustration

Best Practices of Safe Pattern Matching in a Scala Application

avatar

When you start developing your first Scala projects, you may sometimes miss out to address one common problem with pattern matching, and that is error handling. Basically, you may pass a type to a method used for matching and that type doesn't really match any pattern, which will lead to a runtime error.

Say, you created an abstract class Filter, a few case classes that inherit Filter and provide a specific filter type, and a method filterValues() that accepts the filter parameter and uses pattern matching to verify some input.

Here's what the described implementation might look like:

1
2
3
4
5
6
7
8
9
10
abstract class Filter
case class Date(value: ZonedDateTime) extends Filter
case class Category(value: Cat) extends Filter
case class InStock(value: Boolean) extends Filter
case class Price(from: Double, to: Double) extends Filter

def filterValues(filter: Filter): String = filter match {
  case Date(_) => "Filter by date"
  case Category(_) => "Filter by category"
}

Now your colleague, also a Scala developer, creates their own case class that extends Filter to verify some value. But they may forget to add their class as a case parameter in all places where it's used. Eventually, the Scala application you're both working on may fail with the MatchError runtime exception. Needless to say that runtime errors are really bad.

You should always pay attention to this kind of errors when using pattern matching because you can never know when your application may break.

In this article, we have a look at a few ways to handle the MatchError runtime exception in Scala:

  • Adding a default case _ to catch all unmatched options
  • Using the keyword sealed with the base class or trait for matching options (although this is a workaround not a problem solver)
  • Changing the compiler options to stop compilation whenever there are warnings produced thanks to sealed

Step by step, we review the listed ways to address or mitigate the problem.

Using a default case with pattern matching

The first implementation of filterValues() missed possible variants of the parameter type, which is why the compiler won't be able to notice an error. You also won't be able to properly process input parameters.

When using pattern matching, however, you can match an unexpected parameter with an "all-type" option:

1
2
3
4
5
def filterValues(filter: Filter): String = filter match {
  case Date(_) => "Filter by date"
  case Category(_) => "Filter by category"
  case _ => "Other filter"
}

With the implementation above, you can apply a default filter _ (the last case), an irrefutable pattern, which is similar to the default case in Java switch statements. _ matches any kind of data you throw into the method.

To handle the mismatch with case _, you can throw an error, log out a message to the console, or process a case any other way according to the needs of your application.

This solution isn't perfect, though, as you probably want to keep the method implementation without any extra cases.

Using sealed to get warnings when a pattern doesn't match

Scala provides a way to generate warnings whenever some pattern doesn't match. That is, you can use the keyword sealed when defining your abstract class (or trait) for pattern matching. Marking a class with sealed tells that the subtypes must be declared in the same file to ensure that all of them are known.

This is how the updated code looks:

1
2
3
4
5
6
7
8
9
10
sealed abstract class Filter
case class Date(value: ZonedDateTime) extends Filter
case class Category(value: Cat) extends Filter
case class InStock(value: Boolean) extends Filter
case class Price(from: Double, to: Double) extends Filter

def filterValues(filter: Filter): String = filter match {
  case Date(_) => "Filter by date"
  case Category(_) => "Filter by category"
}

If you try to run your code with a sealed class, the Scala compiler will warn you about the issue:

1
2
3
4
5
6
[warn] /home/johndoe/project/PatternMatchingSample.scala:20:39: match may not be exhaustive.
[warn] It would fail on the following inputs: InStock(_), Price(_, _)
[warn]   def filterValues(filter: Filter): String = filter match {
[warn]                                              ^
[warn] one warning found
[info] Done compiling.

However, just placing the keyword sealed before a class doesn't actually solve the problem. You do get cleaner code compared to the use of the irrefutable pattern, though. And to find the problematic use of pattern matching, you can simply inspect the console output to find warnings.

In order not to miss MatchError, you need to configure the Scala compiler to throw errors whenever this exception occurs.

So we want the compiler stop the application compilation and say something like: "Look, man, you have a method that calls your filter but these inputs have failed. Check them, please." The compiler setting you're looking for is called -Xfatal-warnings, which fails the compilation if there are any warnings:

1
2
3
scalacOptions ++= Seq(
  "-Xfatal-warnings"
)

Once you set this options and use sealed with your abstract classes for pattern matching, the Scala compiler will always stop building your project whenever a warning is produced, which is great.

Removing annoying warnings

Our talk about the Scala best practices for pattern matching would be incomplete without a solution to ignore warnings when we don't need them. Often, you can understand from the context that some unexpected type will never be passed as a parameter to your method, and you're sure that MatchError won't be thrown. But if you're using sealed, warnings will still be shown in the console.

Have a look a this example:

1
2
3
4
5
sealed abstract class Move
case class Forward() extends Move
case class Back() extends Move
case class Up() extends Move
case class Down() extends Move

Some method apply() that can match a parameter move to the defined cases for, say, Car objects, may not use all move patterns by design, as shown in the example below, thus producing annoying warnings. To remove them, you can use the annotation @unchecked in the selector:

1
2
3
4
def apply(move: Move): String = (move: @unchecked) match {
  case Forward() => "Move forward"
  case Back() => "Move back"
}

This last example ensures that deep pattern matching won't be executed. Remember to use it with care, as it may lead to downsides described in the beginning of the post.


That’s all you need to know about handling the pattern matching MatchError runtime exception in your Scala application.

If you're looking for a developer or considering starting a new project,
we are always ready to help!