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.