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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.