Scala - Extractor Objects (unapply)
• Extractor objects use the `unapply` method to deconstruct objects into their constituent parts, enabling pattern matching on custom types without exposing internal implementation details
Key Insights
• Extractor objects use the unapply method to deconstruct objects into their constituent parts, enabling pattern matching on custom types without exposing internal implementation details
• Unlike apply which constructs objects, unapply returns an Option containing the extracted components, with Option[TupleN] for multiple values or Boolean for parameterless extraction
• Extractors separate object construction from deconstruction, allowing you to change internal representations while maintaining stable pattern matching interfaces
Understanding Extractor Objects
Extractor objects implement the unapply method to enable pattern matching on custom types. While case classes automatically generate extractors, custom extractors give you control over how objects are deconstructed, validated, and matched.
object Email {
def apply(user: String, domain: String): String =
s"$user@$domain"
def unapply(email: String): Option[(String, String)] = {
val parts = email.split("@")
if (parts.length == 2) Some((parts(0), parts(1)))
else None
}
}
// Usage in pattern matching
val address = "john.doe@example.com"
address match {
case Email(user, domain) =>
println(s"User: $user, Domain: $domain")
case _ =>
println("Invalid email")
}
// Output: User: john.doe, Domain: example.com
The unapply method returns Option[(String, String)] because extraction may fail. Pattern matching automatically unwraps the Option when the match succeeds.
Single Value Extraction
For extracting a single value, unapply returns Option[T] where T is the extracted type.
object PositiveInt {
def unapply(value: Int): Option[Int] =
if (value > 0) Some(value) else None
}
object EvenNumber {
def unapply(value: Int): Option[Int] =
if (value % 2 == 0) Some(value) else None
}
def categorize(n: Int): String = n match {
case PositiveInt(EvenNumber(x)) => s"$x is positive and even"
case PositiveInt(x) => s"$x is positive but odd"
case EvenNumber(x) => s"$x is even but not positive"
case x => s"$x is odd and not positive"
}
println(categorize(8)) // 8 is positive and even
println(categorize(5)) // 5 is positive but odd
println(categorize(-4)) // -4 is even but not positive
Extractors can be nested, creating powerful validation chains without explicit conditional logic.
Boolean Extractors
For parameterless extraction that only checks conditions, use unapplySeq returning Boolean.
object Uppercase {
def unapply(s: String): Boolean =
s.nonEmpty && s.forall(_.isUpper)
}
object Lowercase {
def unapply(s: String): Boolean =
s.nonEmpty && s.forall(_.isLower)
}
def checkCase(text: String): String = text match {
case Uppercase() => "All uppercase"
case Lowercase() => "All lowercase"
case _ => "Mixed case"
}
println(checkCase("HELLO")) // All uppercase
println(checkCase("world")) // All lowercase
println(checkCase("Hello")) // Mixed case
Note the empty parentheses in the pattern Uppercase(). This syntax is required for boolean extractors.
Variable-Length Extraction with unapplySeq
The unapplySeq method handles variable-length sequences, returning Option[Seq[T]].
object Names {
def unapplySeq(text: String): Option[Seq[String]] = {
val names = text.split(",").map(_.trim).filter(_.nonEmpty)
if (names.nonEmpty) Some(names.toSeq) else None
}
}
val input = "Alice, Bob, Charlie"
input match {
case Names(first, second, rest @ _*) =>
println(s"First: $first, Second: $second, Rest: ${rest.mkString(", ")}")
case Names(single) =>
println(s"Only one name: $single")
case _ =>
println("No names found")
}
// Output: First: Alice, Second: Bob, Rest: Charlie
The @ _* syntax captures remaining elements into a sequence.
Practical Example: URL Parser
Extractors excel at parsing and validating structured data.
object URL {
def unapply(url: String): Option[(String, String, String)] = {
val pattern = "^(https?)://([^/]+)(/.*)?$".r
url match {
case pattern(protocol, host, path) =>
Some((protocol, host, Option(path).getOrElse("/")))
case _ => None
}
}
}
object SecureURL {
def unapply(url: String): Option[(String, String)] = url match {
case URL("https", host, path) => Some((host, path))
case _ => None
}
}
def processURL(url: String): Unit = url match {
case SecureURL(host, path) =>
println(s"Secure connection to $host$path")
case URL(protocol, host, path) =>
println(s"Warning: Insecure $protocol connection to $host$path")
case _ =>
println("Invalid URL")
}
processURL("https://api.example.com/v1/users")
// Output: Secure connection to api.example.com/v1/users
processURL("http://legacy.system.com/data")
// Output: Warning: Insecure http connection to legacy.system.com/data
Infix Extraction Patterns
Extractors with two parameters can be used in infix notation for more readable patterns.
object :: {
def unapply[T](list: List[T]): Option[(T, List[T])] = list match {
case Nil => None
case head :: tail => Some((head, tail))
}
}
def sumList(numbers: List[Int]): Int = numbers match {
case head :: tail => head + sumList(tail)
case Nil => 0
}
println(sumList(List(1, 2, 3, 4, 5))) // 15
This pattern mimics Scala’s built-in cons operator, demonstrating how extractors enable custom syntax.
Extractors with Type Parameters
Generic extractors work across different types while maintaining type safety.
object Pair {
def unapply[A, B](tuple: (A, B)): Option[(A, B)] = Some(tuple)
}
object Triple {
def unapply[A, B, C](tuple: (A, B, C)): Option[(A, B, C)] = Some(tuple)
}
def describe(data: Any): String = data match {
case Pair(x: Int, y: Int) => s"Integer pair: $x, $y"
case Pair(x: String, y: String) => s"String pair: $x, $y"
case Triple(x, y, z) => s"Triple: $x, $y, $z"
case _ => "Unknown structure"
}
println(describe((42, 24))) // Integer pair: 42, 24
println(describe(("hello", "world"))) // String pair: hello, world
println(describe((1, 2, 3))) // Triple: 1, 2, 3
Performance Considerations
Extractors introduce overhead through Option allocation and method calls. For performance-critical code, measure before optimizing.
// Potentially expensive
object ParsedInt {
def unapply(s: String): Option[Int] =
try { Some(s.toInt) } catch { case _: NumberFormatException => None }
}
// Optimized version with caching
object CachedParsedInt {
private val cache = scala.collection.mutable.Map[String, Option[Int]]()
def unapply(s: String): Option[Int] =
cache.getOrElseUpdate(s,
try { Some(s.toInt) } catch { case _: NumberFormatException => None }
)
}
For simple extractions on case classes, the compiler-generated extractors are already optimized.
Integration with Case Classes
Combine custom extractors with case classes for flexible pattern matching.
case class Person(firstName: String, lastName: String, age: Int)
object Adult {
def unapply(p: Person): Option[Person] =
if (p.age >= 18) Some(p) else None
}
object Senior {
def unapply(p: Person): Option[Person] =
if (p.age >= 65) Some(p) else None
}
def categorize(person: Person): String = person match {
case Senior(Person(first, last, age)) =>
s"$first $last is a senior citizen ($age years old)"
case Adult(Person(first, last, age)) =>
s"$first $last is an adult ($age years old)"
case Person(first, last, age) =>
s"$first $last is a minor ($age years old)"
}
println(categorize(Person("John", "Doe", 70)))
// Output: John Doe is a senior citizen (70 years old)
Extractors provide abstraction over object internals, enabling you to refactor implementations without breaking pattern matching code. They’re essential for building domain-specific languages, parsers, and validation layers in Scala applications.