FP Error Handling

I would like to handle errors in pure FP (functional programming), something more idiomatic and easier to read than nested try/catch blocks.

Let's say we have the following procedure,

def sumCirclesDiv(rs: List[String], d: Int = 1): Int = {
    val areas = rs.map(r => {math.Pi * r.toDouble * r.toDouble})
    val divs = areas.map(_ / d)
    divs.sum
}

This is pretty straightforward. Given a list of numbers (represented as strings), compute the areas of these as circles, and then divide by a common divisor (default to 1) and return an Integer sum. But what happens if one of the input strings cannot be parsed? What happens if the divisor is 0?

We could use try/catch exception handling, and end up with something like this,

def safeSumCirclesDiv(rs: List[String], d: Int = 1): Int = {
    try {
      val areas = rs.map((r) => {math.Pi * r.toDouble * r.toDouble})
      val divs = areas.map(_.toInt/d)
      divs.sum 
    } catch {
      case x: ArithmeticException => {
        println("you cannot divide by " + d)
        0
      }
      case x: NumberFormatException => {
        println("you must provide parseable numbers")
        0
      }
      case unknown: Throwable => {
        println("Exception: " + unknown)
        0
      }
    }
}

This works, but presents numerous problems. For starters we return Int 0 in the case of an exception. But what if we want to ignore invalid strings and return a value for the valid input strings? We could do something like this,

def saferSumCirclesDiv(rs: List[String], d: Int = 1): Int = {
    var acc = 0
    for (r <- rs) {
      try {
        val area = math.Pi * r.toDouble * r.toDouble
        acc += area.toInt / d
      } catch {
        case x: ArithmeticException => {
          println("you cannot divide by " + d)
        }
        case x: NumberFormatException => {
          println("you must provide parseable numbers")
        }
        case unknown: Throwable => {
          println("Exception: " + unknown)
        }
      }
    }
    acc
}

This will work for lists that contain a mix of valid and invalid string inputs, but it's not exactly idiomatic, the function is not pure, and it's an unwarranted assumption that invalid inputs should result in a zero. A result of zero should not be indicative of input errors.

Option

In Scala (as well as in Functional Java libraries) we can use the Option monad. This is built-in to Scala, although as we'll see, it is fairly easy to implement directly.

For starters, our function signature should not lie (that is, if we can't guarantee an Int, we shouldn't promise an Int). We should instead return an Option of an Int, e.g.,

def optionSumCirclesDiv(rs: List[String], d: Int = 1): Option[Int] = ...

In this monadic approach, we should get the following results,

scala> optionSumCirclesDiv(List("1.5", "4.2"))
res0: Option[Int] = Some(62)

scala> optionSumCirclesDiv(List("1.5", "4.2"), 0)
res1: Option[Int] = None

scala> optionSumCirclesDiv(List())
res2: Option[Int] = None

scala> optionSumCirclesDiv(List("0.0"))
res3: Option[Int] = Some(0)

scala> optionSumCirclesDiv(List("1.0", "foobar"))
res4: Option[Int] = Some(3)

When using Option in Scala, we have either Some or None, and importantly, Some(0) is not the same as None. Let's implement this in pieces, and do so in a way that keeps our functions pure. For starters, we have an input List of strings that could contain invalid inputs (like "foobar" in the above example). We'll want to filter out all entries that cannot be cast to a number. You can do this explicitly with a List.filter expression, but we can also do so by using Option.

def optionDouble(s: String): Option[Double] =
  try { Some(s.toDouble) } catch { case _: Throwable => None }

With this function, we can map over a list and then flatten it (removing the Some and None wrappers), which is the same as using a flatMap(),

scala> List("1.1", "foo", "4.2").map(optionDouble)
res0: List[Option[Double]] = List(Some(1.1), None, Some(4.2))

scala> List("1.1", "foo", "4.2").map(optionDouble).flatten
res1: List[Double] = List(1.1, 4.2)

scala> List("1.1", "foo", "4.2").flatMap(optionDouble)
res2: List[Double] = List(1.1, 4.2)

We can now filter out all invalid inputs, and be guaranteed a List with zero or more valid inputs. Using this same approach, we can define other functions that use Some and None,

def circled(r: Double) = Some(math.Pi * r * r)

def divN(y: Int)(x: Double): Option[Int] =
  try { Some(x.toInt / y) } catch { case _: Throwable => None }

The circled function is obvious, but why create a divN? In this case, we can curry divN (within a flatMap) as follows,

scala> List(2.0, 4.0, 6.0).flatMap(divN(2))
res0: List[Int] = List(1, 2, 3)

scala> List(2.0, 4.0, 6.0).flatMap(divN(3))
res1: List[Int] = List(0, 1, 2)

If we chain together these functions in flatMaps, we'll have exactly what we need to implement optionSumCirclesDiv, i.e.,

def optionSumCirclesDiv(rs: List[String], d: Int = 1): Option[Int] = {
  rs.flatMap(optionDouble).flatMap(circled).flatMap(divN(d)) match {
    case Nil => None
    case x => Some(x.sum)
  }
}

And because we have a chain of flatMaps, we can use a for-comprehension, e.g.,

rs.flatMap(optionDouble).flatMap(circled).flatMap(divN(d))

// is equivalent to..

for {
      s  <- rs
      r  <- optionDouble(s)
      a  <- circled(r)
      ad <- divN(d)(a)
    } yield ad

Either / Try

The only potential problem with using the Option monad is that we're swallowing our errors. Maybe we want to know what specific exception occurred in our input list. A useful (albeit ugly) approach is to use the Either data type. Rather than Some or None, Either can be Left or Right. By convention, Left is used for errors and Right is used for success, e.g.,

def eitherDouble(s: String) = 
  try { Right(s.toDouble) } 
  catch { case _: Throwable => Left("Cannot parse " + s) }
    
def circled(r: Double) = Right(math.Pi * r * r)
    
def divN(y: Int)(x: Double) =
  try { Right(x.toInt / y) } 
  catch { case _: Throwable => Left("Cannot divide by " + y.toString) }

The return type for these functions is Either[String, Int], and we can use them as follows,

def eitherSumCirclesDiv(rs: List[String], d: Int = 1): Either[String, Int] = {
    val list = rs.map(eitherDouble)
                 .map(_.flatMap(circled))
                 .map(_.flatMap(divN(d)))
    list.collect{ case Left(x) => x }.map(println) //side effect!
    list.collect{ case Right(x) => x } match {
      case Nil => Left("No parseable numbers to compute")
      case x => Right(x.sum)
    }
}

A more idiomatic approach to this problem is to use the Try monad. This combines traditional exceptions with a data structure that is similar to Either, but instead of Left/Right we use the more intuitive Failure/Success, e.g.,

import scala.util.{Try,Success,Failure}
     
def tryDivArea(d: Int = 1)(s: String) =
  Try({
    val r = s.toDouble        
    val a = math.Pi * r * r
    a.toInt / d
  }) 

By simply returning a Try() structure, we'll get either Success() or a Failure() object that wraps the corresponding exception (e.g., NumberFormatException). We can use this function as follows,

def trySumCirclesDiv(rs: List[String], d: Int = 1): Try[Int] = {    
    val all = rs.map(tryDivArea(d))
    all.filter(_.isFailure).map(println) //side effect!
    all.filter(_.isSuccess).map(_.get) match {
      case Nil => Failure(new Exception("Empty List"))
      case x => Try(x.sum)
    }
}

In the above cases we're simply printing the exception messages (a side effect to what is otherwise a pure function), and in practice we would not want to do that; keeping our functions pure will allow for proper exception logging as well as parallel streams.

All of the above code can be found on my github, and should run correctly in Scastie.

This entry was posted in scala, software eng.. Bookmark the permalink.