This is the third post in my series documenting the process of writing a Scala package. In the first two posts, I covered (1) the SBT file structure of a Scala package; (2) making a package available in the Scala REPL. Now I want to discuss a crucial part of any function design – error handling.

Three popular patterns for error handling are: Option/Some/None, Either/Left/Right, and Try/Success/Failure. To give this comparison a point of reference, let’s assume that we’re implementing our own division function for integers. I’ll start by defining this function with no error handling and then use each of the patterns I mentioned.

No Error Handling

As a baseline for comparison, let’s consider the case where nothing is done to handle errors. The function will either return an output successfully or throw an error.

Function definition

def frac(a: Int, b: Int): Double = {
  a / b
}

Handling the return

The only type something is returned is when the function successfully returns a value type Double. So there’s nothing to “handle” per-se. To avoid an unforeseen error that crashes our program, we could wrap calls to frac in a Try-Catch block:

try {
  frac(2, 5)
} catch {
  case e: Exception => // do something with the exception
}

Pros and cons

The upside to this option is the brevity of the code; the con is that the caller is responsible for checking conditions and handling errors.

Option/Some/None

This is the method I read about most frequently when I started my Scala journey. Functions that employ this method return None if they cannot return a value, and Some(value) if they can.

Function definition

The return signature is set as Option[successType]. If the function is successful, Some(value: successType) is returned; otherwise None is returned.

def frac(a: Int, b: Int): Option[Double] = {
  try {
    Some(a / b)
  } catch {
    case e: Exception => None
  }
}

Handling the return

A caller can handle an Option in several different ways:

1) use getOrElse to retrieve the actual value if the function succeeded, or a default value if it failed:

frac(0, 1).getOrElse(0)

2) treat the Option as a collection (containing 0 values in the case of None and 1 value in the case of Some):

frac(0, 1).foreach{ result => println(f"Inverse found: $result") }

3) use map to apply a function \(f\) to the Option, returning Some(f(a)) if the Option was Some(a) and returning None otherwise:

frac(0, 1).map(x => x + 1)

Pros and cons

This method fails to return any information to the caller that would be useful in understanding a why it failed to produce a value. For this reason, the methods below are more attractive. However, Option can be useful if it makes sense for a function to not have an output for every input. In this case, returning None to the caller indicates that nothing failed but that there is no sensical output for the given input.

Either/Left/Right

This method is similar to Option/Some/None in that a successful function call returns the value wrapped in a container. A successful return looks like Right(value: successType) while a failure looks like Left(failValue: failType). failType can be any type, allowing the function to send an error message to the caller.

Function definition

The return signature is set to Either[failureType, successType]. If the function is successful, Right(value: successType) is returned; otherwise Left(value: failureType) is returned. Note: using Right for successful returns is conventional, it is not required (but you really should).

def frac(a: Int, b: Int): Either[String, Double] = {
  if (b != 0) {
    Right(a / b)
  } else {
    Left("The second argument (b) cannot be equal to 0.")
  }
}

Handling the return

1) Pattern matching:

frac(0, 4) match {
  case Left(s)  => // do something with error message
  case Right(d) => // do something with returned Double
}

2) use map to apply a function \(f\) to Right(x: successType), yielding Right(f(x)); if the same map is applied to Left(y: failType), Left(y) is returned unchanged:

frac(0, 4).map(d => f(d))

3) a) for-yield comprehension is similar to option (2) in case there is one result:

for {
  d <- frac(2, 5)
} yield {
  // do something with d
} 

In the case that frac(2, 5) is Left, the yield section is skipped and the Left value is returned.

3) b) for-yield is especially useful for multiple results:

for {
  a <- frac(1, 4)
  b <- frac(4, 7)
  c <- frac(2, 3)
} yield {
  // do something with `a`, `b`, and `c`
}

In the case that any of the function calls return a Left, the yield section is skipped and the first Left value is returned.

Note: Return handling methods (2) and (3) make use of the right-biased nature of Either. As put by the docs, “right-biased” means that “Right is assumed to be the default case to operate on. If it is Left, operations like map, flatMap, … return the Left value unchanged.”

Pros and cons

Unlike Option/Some/None, Either allows the function to return a descriptive error message. However, other functions that call functions with return signature Either[typeA, typeB] must always implement methods to deal with typeA and typeB. In the case of many nested functions, this can lead to a lot of boilerplate.

Try/Success/Failure

Try is essentially the same as Either where the Left type is restricted to Throwable.

Function definition

import scala.util.{Try,Success,Failure}

def frac(a: Int, b: Int): Try[Int] = Try {
  a / b
}

Handling the return

All of the return handling methods for Either/Left/Right apply here as well. Instead of Right(x: successType), there is Success(x: successType); instead of Left(y: failType), there is Failure(y: Throwable).

Pros and cons

Since the “Left” return type is always Throwable, this reduces the complexity of handling returns from a function with signature Try[A]. However, this also makes Try inherently less flexible than Either.

Parting Thoughts

After careful consideration of the above methods, I ultimately chose to use Either/Left/Right for my package. I started out with Option/Some/None for the ease of use but soon realized I wanted to provide users with flexible error messages. Try/Success/Failure was a close second, but I didn’t want to deal with the complexity of developing my own exceptions.

Try implementing each of the above methods for a set of nested functions and see which one you like best!

Here are a few write-ups that I found useful: