Field Notes

unapply decides what catch catches: Scala extractors in match and try/catch

unapply decides what catch catches: Scala extractors in match and try/catch

I knew unapply as the thing case classes generate so that case Point(x, y) works. Then I saw it used inside a catch block to decide which exceptions to handle, and realised I didn't really know when the compiler calls it or what happens when it says no. So I built a small runnable demo in a Scala 2.13.18 sbt project and put a println inside every extractor to watch the matcher work.

What a pattern actually calls

When you write case Point(x, y), the compiler doesn't look at a constructor. It looks for a method called unapply on the Point object and calls it with the value being matched. For a case class that method is generated for you. For anything else you write it yourself, and the return type decides the shape of the pattern:

  • Option[A]: the pattern binds one value, and None means "no match, try the next case".
  • Option[(A, B)]: the pattern binds two values, as in case Email(user, domain).
  • Boolean: the pattern binds nothing and is just a test, as in case Internal().
  • Some[...] instead of Option[...] tells the compiler the extractor can never fail. That matters for exhaustivity checks.
Flow: case Recoverable(err) makes the compiler call Recoverable.unapply(ex), which returns Some(err) to bind and run the body, or None to try the next case; plus the three unapply return shapes
A pattern is a method call: the return type of unapply decides what the pattern binds.

The object you match on doesn't even need to be built by the extractor. My demo has a Port extractor that parses a String:

object Port {
  def unapply(s: String): Option[Int] =
    s.toIntOption.filter(p => p > 0 && p < 65536)
}

List("8080", "99999", "abc").foreach {
  case Port(p) => println(s"'$p' is a valid port")
  case other   => println(s"'$other' rejected")
}

So an extractor is just a function from "the thing being matched" to "maybe some parts". It works as a parser, a validator and a classifier with match syntax.

catch is a match on Throwable

In Scala, the block after catch is a partial function from Throwable. Every case is tried in order, and if none matches, the exception is rethrown unchanged. So a custom extractor in a catch is also a filter: it decides which exceptions you handle and which ones keep going up the stack.

To try this, I modelled a small CSV order importer. ImportError is a sealed family of exceptions with a short code: BadRow for data problems the importer understands, and Unexpected for anything else. The Recoverable extractor puts the policy in one place:

object Recoverable {
  def unapply(ex: Throwable)(implicit site: CallSite): Option[ImportError] =
    ex match {
      case ImportError(err) => Some(err)                           // already ours: pass through
      case NonFatal(err)    => Some(ImportError.Unexpected(err, site)) // JVM exception: wrap it
      case _                => None                                // fatal: refuse, so catch rethrows
    }
}

try importRow(line)
catch {
  case Recoverable(err) =>
    println(err.summary.linesIterator.nextOption().getOrElse("<empty>"))
}
The three branches of Recoverable.unapply: ImportError passes through, NonFatal is wrapped as Unexpected, fatal errors return None
The narrow case goes first, and None is how fatal errors keep propagating.

The three branches, as they actually ran

I threw three different things through the same catch. Running sbt "runMain lu.zakaria.extractors.ExtractorDemo" printed:

Actual sbt output: BadRow and NumberFormatException are matched by Recoverable, InterruptedException is rethrown to the outer catch
Same catch, three throws: two handled, the interrupt rethrown.

The importer's own BadRow comes out unchanged with its code. A NumberFormatException from parsing "12,5" is wrapped as Unexpected. The InterruptedException doesn't match, so the outer catch receives it untouched. That last result is the one that matters most. scala.util.control.NonFatal treats VirtualMachineError (out of memory, stack overflow), ThreadDeath, InterruptedException, LinkageError and ControlThrowable as fatal. If you wrap a cancellation into an import error and carry on, the import can't be stopped.

Order matters inside the extractor

An ImportError is itself an ordinary, non-fatal exception. If case NonFatal came first, every BadRow would be re-wrapped as Unexpected and lose its line number and reason. The narrow case goes first. This is the same rule as ordering catch clauses in Java, but now it lives in one reusable place instead of being repeated at every call site.

Extractors also compose. Because Unexpected and CallSite are case classes, you can pattern into what Recoverable returns:

t match {
  case Recoverable(ImportError.Unexpected(cause, CallSite(where))) =>
    println(s"unexpected ${cause.getClass.getSimpleName}, caught at $where")
  case _ => println("no match")
}

With an ArithmeticException that prints unexpected ArithmeticException, caught at OrderImporter.run. The same extractor works in a plain match because catch was a match all along.

The implicit parameter on unapply

The detail that surprised me most is the second parameter list: unapply(ex: Throwable)(implicit site: CallSite). Scala allows it, and the implicit is resolved where the pattern is written, not where the extractor is defined. In the demo, one implicit val site = CallSite("OrderImporter.run") flows into every case Recoverable(...) without appearing in the pattern. The trace lines show it arriving: Recoverable.unapply(BadRow) site=OrderImporter.run. That keeps the call site down to one readable line, while every wrapped error still records where it was caught.

One small trap

My first version of the handler used linesIterator.next() to print the first line of the message. On an empty string that throws NoSuchElementException from inside the error handler, which is the worst place to raise a new error. nextOption() with a fallback avoids it.

When I'd reach for this

A custom extractor earns its place when the same "is this one of ours, can we recover, or should we let go" decision is repeated across many catch blocks. It moves that policy into one tested object and keeps call sites down to one line. If you need the decision only once, a type pattern plus NonFatal inline is clearer. Either way, the rule I'm keeping is that a None from unapply is not a failure. It's how the extractor lets the exceptions you must not handle keep propagating.

Keep reading