Summary:
I want to have options for validation prior to instantiating a Scala case class as opposed to having to use the requires/IllegalArgumentException mechanism. Is there a way to avoid Java-like boilerplate code when attempting to pre-validate parameters intended for a Scala case class instantiation?
Details:
Coming from Java, I've now used Scala case classes enough to thoroughly enjoy and appreciate how much boilerplate it eliminates. However, I am now hitting a problem which seems to be boilerplate ballooning my Scala code around case classes considerably.
Please consider the following code for my typical Scala case class (copied directly from IntelliJ's Scala Worksheet - NOTE: Everything above the case class Surface1 is there to reduce code noise when presenting Surface2):
val LONGITUDE_MAX = 180.0d
val LATITUDE_MAX = 90.0d
def isLongitudeValid(longitude: Double) = (-LONGITUDE_MAX <= longitude) && (longitude <= LONGITUDE_MAX)
def isLatitudeValid(latitude: Double) = (-LATITUDE_MAX <= latitude) && (latitude <= LATITUDE_MAX)
object SurfaceType extends Enumeration {
type SurfaceType = Value
val SEA, LAND, ICE = Value
}
def isSurfaceTypeCorrect(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value) = true //fabricated validation assuming longitude and latitude are validated
//
//#1. Simple case class use
case class Surface1(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) {
require(isLongitudeValid(longitude), s"longitude [$longitude] must be greater than or equal to -$LONGITUDE_MAX and less than or equal to $LONGITUDE_MAX")
require(isLatitudeValid(latitude), s"latitude [$latitude] must be greater than or equal to -$LATITUDE_MAX and less than or equal to $LATITUDE_MAX")
require(isSurfaceTypeCorrect(longitude, latitude, surfaceType), s"for longitude[$longitude] and latitude[$latitude], surfaceType [$surfaceType] must be the correct")
}
So, given the above for Surface1, here are some example usages:
val surface1a = Surface1()
val surface1b = Surface1(0.0d, 0.0d, SurfaceType.SEA)
val surface1c = Surface1(-180.0d, -90.0d, SurfaceType.ICE)
val surface1d = Surface1(-180.1d, -90.1d, SurfaceType.ICE) //generates an IllegalArgumentException
The first three will generate a correct instance. The last will generate an IllegalArgumentException...BUT ONLY FOR LONGITUDE. Latitude is also erred. But with the standard Scala case class model, the validation for latitude an surfaceType are won't be evaluated. It would be very helpful for at least latitude's validation to have executed also and received a list of exceptions.
So, after numerous tangents, this is what I arrived at to be able to pre-instantiation validation where I can do any of; continue to instantiate with the standard requires/exception model (apply), return an Option (get) or return Either (create):
//#2. Explicit validators for case class use
type CaseClassValidationException = IllegalArgumentException
object Surface2 {
def validate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[List[CaseClassValidationException]] = {
val errorsA =
List(
if (!isLongitudeValid(longitude)) Some(new CaseClassValidationException(s"longitude [$longitude] must be greater than or equal to -$LONGITUDE_MAX and less than or equal to $LONGITUDE_MAX")) else None
, if (!isLatitudeValid(latitude)) Some(new CaseClassValidationException(s"latitude [$latitude] must be greater than or equal to -$LATITUDE_MAX and less than or equal to $LATITUDE_MAX")) else None
)
val errorsB =
if (errorsA.isEmpty) //these checks depend upon the errorsA checks
List(
if (!isSurfaceTypeCorrect(longitude, latitude, surfaceType))
Some(new CaseClassValidationException(s"for longitude[$longitude] and latitude[$latitude], surfaceType [$surfaceType] must be the correct"))
else None
)
else Nil
val errors = (errorsA ::: errorsB).flatten
if (!errors.isEmpty) Some(errors)
else None
}
def get(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Option[Surface2] = {
create(longitude, latitude, surfaceType) match {
case Right(surface2) => Some(surface2)
case Left(errors) => None
}
}
def create(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Either[List[CaseClassValidationException], Surface2] = {
validate(longitude, latitude, surfaceType) match {
case Some(errors) => Left(errors)
case None => Right(new Surface2(longitude, latitude, surfaceType, false))
}
}
}
case class Surface2 private (longitude: Double, latitude: Double, surfaceType: SurfaceType.Value, executeValidate: Boolean) {
def this(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA) = this(longitude, latitude, surfaceType, true)
if (executeValidate) require(Surface2.validate(longitude, latitude, surfaceType).isEmpty, "failed validate")
}
So, given the above for Surface2, here are some example usages:
val surface2a = new Surface2()
val surface2b = new Surface2(0.0d, 0.0d, SurfaceType.SEA)
val surface2c = new Surface2(-180.0d, -90.0d, SurfaceType.ICE)
val surface2d = new Surface2(-180.1d, -90.1d, SurfaceType.ICE) //generates an IllegalArgumentException
val surface2option1a = Surface2.get()
val surface2option1b = Surface2.get(0.0d, 0.0d, SurfaceType.SEA)
val surface2option1c = Surface2.get(-180.0d, -90.0d, SurfaceType.ICE)
val surface2option1d = Surface2.get(-180.1d, -90.1d, SurfaceType.ICE) //None
val surface2either1a = Surface2.create()
val surface2either1b = Surface2.create(0.0d, 0.0d, SurfaceType.SEA)
val surface2either1c = Surface2.create(-180.0d, -90.0d, SurfaceType.ICE)
val surface2either1d = Surface2.create(-180.1d, -90.1d, SurfaceType.ICE) //Left[...]
The first four produce the same results as Surface1 does. However, the remaining 8 will only instantiate when the validate method returns None. So, I have achieved my desired effects, but at a code smell cost. Here are the issues I have with the solution:
- Inefficient instantiation space usage with the extra executeValidate parameter
- Exposed implementation detail - executeValidate parameter
- Much larger code surface (i.e. boilerplate) increasing the probability of introducing errors/bugs
I have dozens of case classes I will be using in my current project. And it sure feels awfully heavy to have to add this to each case class as I define it. Surely I am overlooking something where I can substantially simplify producing the desired effects without the listed undesired effects.
Any guidance you can offer would be greatly appreciated.
UPDATE 2014-06-13:
It appears (without using Scalaz) there is no real means to significantly reduce the boilerplate. That said, thanks to NikitaVolkov and his aligning with my preference to avoid using Exceptions as much as possible, I realized that I don't need the "apply" method in the case class itself (which is what forces the extra inefficient executeValidate parameter for the private case class constructor). This was a significant simplification of the case class itself; i.e. removing the exception throwing apply method entirely.
Below is the latest version, Surface3, which achieves all my desired effects and while shorter and simpler, is still pretty long on the boilerplate side:
object SurfaceType extends Enumeration {
type SurfaceType = Value
val SEA, LAND, ICE = Value
}
type CaseClassValidationException = IllegalArgumentException
object Surface3 {
def longitudeValidate(longitude: Double): Option[CaseClassValidationException] = {
val longitudeBound = 180.0d
if (!((-longitudeBound <= longitude) && (longitude <= longitudeBound)))
Some(new CaseClassValidationException(s"longitude [$longitude] must be greater than or equal to -$longitudeBound and less than or equal to $longitudeBound"))
else None
}
def latitudeValidate(latitude: Double): Option[CaseClassValidationException] = {
val latitudeBound = 90.0d
if (!((-latitudeBound <= latitude) && (latitude <= latitudeBound)))
Some(new CaseClassValidationException(s"latitude [$latitude] must be greater than or equal to -$latitudeBound and less than or equal to $latitudeBound"))
else None
}
def surfaceTypeValidate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[CaseClassValidationException] = None //fabricated validation assuming longitude and latitude are validated
private def fullValidate(longitude: Double, latitude: Double, surfaceType: SurfaceType.Value): Option[List[CaseClassValidationException]] = {
val errors1 =
List(
longitudeValidate(longitude)
, latitudeValidate(latitude)
)
val errors2 =
if (errors1.isEmpty)
List(
surfaceTypeValidate(longitude, latitude, surfaceType)
)
else Nil
val errorsFinal = (errors1 ::: errors2).flatten
if (errorsFinal.nonEmpty) Some(errorsFinal)
else None
}
def createEither(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Either[List[CaseClassValidationException], Surface3] =
fullValidate(longitude, latitude, surfaceType) match {
case Some(errors) => Left(errors)
case None => Right(new Surface3(longitude, latitude, surfaceType))
}
def createOption(longitude: Double = 0.0d, latitude: Double = 0.0d, surfaceType: SurfaceType.Value = SurfaceType.SEA): Option[Surface3] =
createEither(longitude, latitude, surfaceType) match {
case Right(surface3) => Some(surface3)
case Left(_) => None
}
}
case class Surface3 private (longitude: Double, latitude: Double, surfaceType: SurfaceType.Value)
//
val surface3option1a = Surface3.createOption()
val surface3option1b = Surface3.createOption(0.0d, 0.0d, SurfaceType.SEA)
val surface3option1c = Surface3.createOption(-180.0d, -90.0d, SurfaceType.ICE)
val surface3option1d = Surface3.createOption(-180.1d, -90.1d, SurfaceType.ICE) //None
val surface3either1a = Surface3.createEither()
val surface3either1b = Surface3.createEither(0.0d, 0.0d, SurfaceType.SEA)
val surface3either1c = Surface3.createEither(-180.0d, -90.0d, SurfaceType.ICE)
val surface3either1d = Surface3.createEither(-180.1d, -90.1d, SurfaceType.ICE) //Left[...]