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, andNonemeans "no match, try the next case".Option[(A, B)]: the pattern binds two values, as incase Email(user, domain).Boolean: the pattern binds nothing and is just a test, as incase Internal().Some[...]instead ofOption[...]tells the compiler the extractor can never fail. That matters for exhaustivity checks.

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, as they actually ran
I threw three different things through the same catch. Running sbt "runMain lu.zakaria.extractors.ExtractorDemo" printed:

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.