Error handling: Monad Error for the rest of us
Error handling has always been a problem not entirely solved in software development. Multiple approaches are used, from error codes to exceptions, all of them bringing along their own problems. In this post we aim to demonstrate a practical use of the Monad Error type class and how it can be used to develop generic error handling code.
All code snippets shown in this post are available on this repository.
Error handling in Scala
In order to propagate and handle application errors, Scala developers, especially those inclined to use functional programming, are accustomed to using monadic data types (Option, Try, Either, etc.) instead of exceptions or returning null.
Example
We are going to use, as an example, a function that takes a string as a parameter (which might fail to parse) and returns a parsed JSON object.
All imports will be further omitted to reduce the amount of code shown here.
def toJsonOpt(str: String): Option[Json] = { // (some code doing the actual parsing and will be further omitted in this post) if (success) Some(result) else None } def toJsonEither(str: String): Either[String, Json] = if (success) Right(result) else Left("Could not parse JSON String")
Compared to other types of error handling, this one brings multiple advantages:
- avoiding Null Pointer Exceptions in the absence of a return value
- avoiding Exception handling/declaration
- explicit errors on the type system
Unfortunately, it also has some downsides:
- usage of different error-handling datatypes by multiple libraries
- error-handling datatypes must be specified when writing the code
This might become a problem when interacting with multiple libraries which use different error handling types or if you are developing a library and you want it to be as generic and compatible with existing code as possible.
The following code does not compile since the error-handling monad is different for both methods.
def readFile(): Try[String] = ??? def toJson(str: String): Either[Throwable, Json] = ??? for { fileContent <- readFile() parsedFile <- toJson(fileContent) } yield parsedFile
Let’s say we want to implement the previous function toJson
and we want it to handle the error functionally while keeping the monad wrapping the error generic.
Pseudo-code of what we would like:
def toJson[F[_]](str: String): F[Json] =
if (success) SUCCESS_CASE else ERROR_CASE
This allows users to specify the desired monad when calling the function.
How could we implement this?
Monad error to the rescue
MonadError is a type class that abstracts over error-handling monads, making it possible to raise or handle errors functionally while keeping the monad generic.
For the following examples we use cats‘ implementation of the MonadError
type class.
def toJson[F[_]](str: String)(implicit M: MonadError[F, Throwable]): F[Json] = if (success) M.pure(result) else M.raiseError(ParseException("Could not parse JSON String"))
Now we can use the function with any type F[_]
which has an instance of MonadError[F, Throwable]
:
val parsedTry: Try[Json] = toJson[Try](content) val parsedEither: Either[Throwable, Json] = toJson[Either[Throwable, ?]](content)
In case you are wondering what the ?
in Either[Throwable, ?]
is, check out the kind-projector compiler plugin for an explanation.
Useful methods
The MonadError
type class implements some methods (besides the Monad
ones) that might be useful for creating and transforming the wrapper monad.
pure
andraiseError
– create a value with a successful value or with an error, respectively.fromEither
/fromTry
/fromOption
/fromValidated
– transforms a specific error monad into the specified MonadErrorhandleError
/handleErrorWith
/recover
/recoverWith
– the same semantic asTry
‘s methods with the same name
Abstracting the error
Our code is more generic than before, but we still have to specify the error’s type when defining the function. Ideally, we would also like it if the function caller could specify its type (with some restrictions).
This can be accomplished by creating a type class to abstract an error that can be created from a set of arguments. It would also be possible to receive, as a parameter, a function that creates the error from those arguments, i.e. Throwable => E
, E
being the desired error type.
In the following example, we define a generic error that can be created from a String or a Throwable, let’s call it UIError.
trait UIError[A] { def errorFromString(str: String): A def errorFromThrowable(thr: Throwable): A }
We can implement the function with the UIError:
def toJson[F[_], E](str: String)(implicit M: MonadError[F, E], E: UIError[E]): F[Json] = if (success) M.pure(result) else M.raiseError(E.errorFromString("Could not parse JSON String"))
Now we can also specify the error’s type when calling the function, as long as there is an instance of UIError
for this type (have a look at the repository for example implementations)
val parsedTry: Try[Json] = toJson[Try, Throwable](content) val parsedEither: Either[String, Json] = toJson[Either[String, ?], String](content)
Wrapping up
We have seen monadic datatypes can be used as an alternative to raise and handle errors and the advantages they bring over conventional approaches. Unfortunately bringing some problems, when writing generic and compatible code is a priority. Using MonadError
we are able to solve most of these issues while keeping the code fairly simple.
Don’t forget to check out the repository for more examples and to try it out for yourself.
Codacy is used by thousands of developers to analyze billions of lines of code every day!
Getting started is easy – and free! Just use your GitHub, Bitbucket or Google account to sign up.