If you're willing to use Scalaz, it has a handful of tools that make this kind of task more convenient, including a new Validation
class and some useful right-biased type class instances for plain old scala.Either
. I'll give an example of each here.
Accumulating errors with Validation
First for our Scalaz imports (note that we have to hide scalaz.Category
to avoid the name conflict):
import scalaz.{ Category => _, _ }
import syntax.apply._, syntax.std.option._, syntax.validation._
I'm using Scalaz 7 for this example. You'd need to make some minor changes to use 6.
I'll assume we have this simplified model:
case class User(name: String)
case class Category(user: User, parent: Category, name: String, desc: String)
Next I'll define the following validation method, which you can easily adapt if you move to an approach that doesn't involve checking for null values:
def nonNull[A](a: A, msg: String): ValidationNel[String, A] =
Option(a).toSuccess(msg).toValidationNel
The Nel
part stands for "non-empty list", and a ValidationNel[String, A]
is essentially the same as an Either[List[String], A]
.
Now we use this method to check our arguments:
def buildCategory(user: User, parent: Category, name: String, desc: String) = (
nonNull(user, "User is mandatory for a normal category") |@|
nonNull(parent, "Parent category is mandatory for a normal category") |@|
nonNull(name, "Name is mandatory for a normal category") |@|
nonNull(desc, "Description is mandatory for a normal category")
)(Category.apply)
Note that Validation[Whatever, _]
isn't a monad (for reasons discussed here, for example), but ValidationNel[String, _]
is an applicative functor, and we're using that fact here when we "lift" Category.apply
into it. See the appendix below for more information on applicative functors.
Now if we write something like this:
val result: ValidationNel[String, Category] =
buildCategory(User("mary"), null, null, "Some category.")
We'll get a failure with the accumulated errors:
Failure(
NonEmptyList(
Parent category is mandatory for a normal category,
Name is mandatory for a normal category
)
)
If all of the arguments had checked out, we'd have a Success
with a Category
value instead.
Failing fast with Either
One of the handy things about using applicative functors for validation is the ease with which you can swap out your approach to handling errors. If you want to fail on the first instead of accumulating them, you can essentially just change your nonNull
method.
We do need a slightly different set of imports:
import scalaz.{ Category => _, _ }
import syntax.apply._, std.either._
But there's no need to change the case classes above.
Here's our new validation method:
def nonNull[A](a: A, msg: String): Either[String, A] = Option(a).toRight(msg)
Almost identical to the one above, except that we're using Either
instead of ValidationNEL
, and the default applicative functor instance that Scalaz provides for Either
doesn't accumulate errors.
That's all we need to do to get the desired fail-fast behavior—no changes are necessary to our buildCategory
method. Now if we write this:
val result: Either[String, Category] =
buildCategory(User("mary"), null, null, "Some category.")
The result will contain only the first error:
Left(Parent category is mandatory for a normal category)
Exactly as we wanted.
Appendix: Quick introduction to applicative functors
Suppose we have a method with a single argument:
def incremented(i: Int): Int = i + 1
And suppose also that we want to apply this method to some x: Option[Int]
and get an Option[Int]
back. The fact that Option
is a functor and therefore provides a map
method makes this easy:
val xi = x map incremented
We've "lifted" incremented
into the Option
functor; that is, we've essentially changed a function mapping Int
to Int
into one mapping Option[Int]
to Option[Int]
(although the syntax muddies that up a bit—the "lifting" metaphor is much clearer in a language like Haskell).
Now suppose we want to apply the following add
method to x
and y
in a similar fashion.
def add(i: Int, j: Int): Int = i + j
val x: Option[Int] = users.find(_.name == "John").map(_.age)
val y: Option[Int] = users.find(_.name == "Mary").map(_.age) // Or whatever.
The fact that Option
is a functor isn't enough. The fact that it's a monad, however, is, and we can use flatMap
to get what we want:
val xy: Option[Int] = x.flatMap(xv => y.map(add(xv, _)))
Or, equivalently:
val xy: Option[Int] = for { xv <- x; yv <- y } yield add(xv, yv)
In a sense, though, the monadness of Option
is overkill for this operation. There's a simpler abstraction—called an applicative functor—that's in-between a functor and a monad and that provides all the machinery we need.
Note that it's in-between in a formal sense: every monad is an applicative functor, every applicative functor is a functor, but not every applicative functor is a monad, etc.
Scalaz gives us an applicative functor instance for Option
, so we can write the following:
import scalaz._, std.option._, syntax.apply._
val xy = (x |@| y)(add)
The syntax is a little odd, but the concept isn't any more complicated than the functor or monad examples above—we're just lifting add
into the applicative functor. If we had a method f
with three arguments, we could write the following:
val xyz = (x |@| y |@| z)(f)
And so on.
So why bother with applicative functors at all, when we've got monads? First of all, it's simply not possible to provide monad instances for some of the abstractions we want to work with—Validation
is the perfect example.
Second (and relatedly), it's just a solid development practice to use the least powerful abstraction that will get the job done. In principle this may allow optimizations that wouldn't otherwise be possible, but more importantly it makes the code we write more reusable.
null
s in Scala and useOption[..]
instead where appropriate. – ReticularOption
s. Look for how to do this with the Scalaz library if you are interested. Otherwise, just apply the method inside a for-comprehension. – Mythos