The case for unapply

Scala’s unapply method is often left unconsidered although it presents an elegant way to solve some specific problems.

The situation we’re going to look at in this article is when we need to add constructors (that can possibly failed to a class).

First let’s take a concrete example with a simple value class:

final case class Id(value: Int) extends AnyVal

One quick side note: Using an Int to represent an identifier is probably not the best idea, but let’s stick to the example.

Now assuming that we need to read our Id from a string we need some constructor method that can turn a String into a proper Id instance.

One convenient place to add such methods is the companion object:

object Id {
  def fromString(s: String): Try[Id] = Try(Id(s.toInt))
}

Because a String isn’t always possible to be parsed into an Int this function returns a Try[Id].

What does Try gives us? It makes it clear that we might not be able to create an Id from a String – which is already much better than the toInt method on String.

However the only exception we can get out of it is a NumberFormatException so we could have returned an Either[NumberFormatException, Id] and again because we can either can an Id or not we could have simply returned an Option[Id]. Obviously if we get back a None it’s clearly because our String wasn’t parseable into an Int.

object Id {
  def fromString(s: String): Option[Id] = Try(Id(s.toInt)).toOption
}

And this is where unapply comes into play. Let’s just rename fromString into unapply:

object Id {
   def unapply(s: String): Option[Id] = Try(Id(s.toInt)).toOption
}

What does it give us?

Because unapply is what powers the pattern matching we can now pattern match our String into an id

"123" match {
  case Id(id) => println("Sweet! I've parsed ${id.value}")
  case s => println("$s isn't a valid Id")
} 

Deconstructing an Id with Id itself doesn’t feel very intuitive as it doesn’t match the shape of our Id class. This bit:

case Id(id) => // here id is an Id itself

It doesn’t match the shape of our Id class

final case class Id(value: Int) extends AnyVal

If id was an Int it would have felt more intuitive. Fortunately this can be easily changed:

object Id {
  def unapply(s: String): Option[Int] = Try(s.toInt).toOption
}

Now our pattern-matching feels a bit more natural:

"123" match {
  case Id(value) => println("Sweet! I've parsed $value") // value is an Int
  case s => println("$s isn't a valid Id")
}

What’s more? We can add more validation. For instance let’s say our ids are only made of positive numbers. We just add that condition to our unapply method.

object Id {
  def unapply(s: String): Option[Int] = Try(s.toInt).toOption.filter(_ > 0)
}

and you can still use unapply directly every time you need to parse a String into an Id.

val s = "123" // or whatever your string comes from
Id.unapply(s).map(id => /* do something with the id */)

And that’s how you can unleash the power of unapply into pattern matching statements.