Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Functional Error Handling and Validation with Cats

Functional Error Handling and Validation with Cats

There is a place where all the fancy abstractions of functional programming really shine: error handling and data validation. During this talk, we will take a whirlwind tour of what the Scala language, supplemented by the cats library, have to offer in that respect:

Option and Either to expressively represent computation that may fail.
Validated for checking pre-conditions on our programs’ inputs while nicely accumulating all encountered errors.
Then we will see how the “three musketeers of functional programming,” Applicative, Monad and Traverse (along with Functor as d’Artagnan) can help us handle our errors and validate our data in a simple, expressive, and composable way!

The video for this talk given by Valentin Kasas as part of the 47 Degrees Academy can be found here: https://www.youtube.com/watch?v=Hj4fRrlKHeY

47 Degrees Academy

June 23, 2020
Tweet

More Decks by 47 Degrees Academy

Other Decks in Programming

Transcript

  1. Trust trust [noun]: rm belief in the reliability, truth, or

    ability of someone or something. acceptance of the truth of a statement without evidence or investigation. the state of being responsible for someone or something.
  2. Trust in software Notions of in and out inside: CPU,

    RAM, compiler outside: everything else (disk, network, etc.) we must trust what's inside we cannot trust anything from the outside
  3. Paranoid programmer Errors may happen everytime we interact with the

    outside We must validate everything that comes inside
  4. When everything is broken.. We should always follow this mantra:

    Fail fast, and fail loudly Always report unexpected behaviours ASAP and with as much information as possible.
  5. Acknowledge everything is broken Make errors "apparent" by wrapping functions'

    result in "error advertising types" "free" documentation about possible errors enforces error management at compile time
  6. Option When a function returns Option[A], it says that it

    may fail to produce an A. Safe, as long as we never call get. def safeDivide(num: Float, denom: Float): Option[Float] = if (denom == 0.0f) None else Some(num / denom)
  7. Try When we know that an operation may throw an

    exception, we can wrap it in a Try. This will return a Success containing an Int or a Failure containing a Throwable. import scala.util.Try def safeParseInt(input: String): Try[Int] = Try(input.toInt)
  8. Either Option[A] means: returns A or Nothing. Try[A] means: returns

    A or Throwable. Either[B, A] means: returns A or B. That's why we often prefer Either to Try or Option, it's way more general.
  9. Either example def safeDivide2(n: Float, d: Float): Either[String, Float] =

    if (d == 0.0f) Left("Division by zero") else Right(n / d) import scala.util.Try def safeParseInt(input: String): Either[String, Int] = Try(input.toInt) .toEither .left .map{ t => s"Couldn't parse $input, because '${t.getMessage}'" }
  10. Composing programs with Option/Try/Either Never use the unsafe methods such

    as get. def average(values: List[Float]): Float = safeDivide(values.sum, values.size.toFloat).get But then, what can we do?
  11. Answer 1: unwrapping the context The rst solution would be

    to somehow "recover" from the error to safely get "out" of our Option/Try/Either. For example: def average(values: List[Float]): Float = safeDivide(values.sum, values.size.toFloat).getOrElse(0.0)
  12. Uwrapping with fold Option has getOrElse, Try and Either both

    have a fold method: trait Try[A] { def fold[B](onFailure: Throwable => B, onSuccess: A => B): B } trait Either[A, B] { def fold[C](onLeft: A => B, onRight: B => C): B }
  13. Manipulating the happy path You may have recognized map. def

    happyPathOpt(in: Option[Int]): Option[Int] = in.map(_ + 1) def happyPathTry(in: Try[Int]): Try[Int] = in.map(_ + 1) def happyPathEither(in: Either[String, Int]) : Either[String, Int] = in.map(_ + 1)
  14. Abstracting over the context It would be nice to have

    a single happyPath function that would work the same for Option/Try/Either though.
  15. Enters cats The cats library provides us exactly that: ways

    to abstract over this kind of "contexts".
  16. Using Functor Using Functor we can write the following And

    it will work for Option/Try/Either! import scala.language.higherKinds import cats._, implicits._ def happyPath[F[_]: Functor](in: F[Int]): F[Int] = in.map(_ + 1)
  17. Chaining operations This is exactly what the for-comprehension is meant

    for: def showAddress(request: Request): Either[Err, Address] = for { id <- parseId(request) // Either[Err, AccountId] account <- fetchAccount(id) // Either[Err, Account] } yield account.address // Either[Err, Address]
  18. Using for-comprehensions To use a for-comprehension, every step of the

    sequence must sit in the same context. In other words: we cannot mix Option, Try and Either in the same for-comprehension. // This wouldn't compile def showAddress(request: Request): Either[Error, Address] = for { id <- parseId(request) // Try[AccountId] account <- fetchAccount(id) // Option[Account] } yield account.address
  19. Unifying contexts We need to lift all operations in the

    same context. Since Either is the most general of the three, we often choose it. def showAddress(request: Request): Either[Error, Address] = for { id <- parseId(request) .toEither.left .map(throwableToError) account <- fetchAccount(id) .toRight(AccountNotFoundError) } yield account.address
  20. Better: abstracting over Monad As we did with Functor, we

    can abstract over Monad import scala.language.higherKinds import cats._, implicits._ def parseId[F[_]: Monad](req: Request): F[AccountId] = ??? def fetchAccount[F[_]]: Monad](id: AccountId): F[Account] = ??? def showAddress[F[_]: Monad](req: Request): F[Address] = for { id <- parseId(req) account <- fetchAccount(id) } yield account.address
  21. Even better: MonadError Abstract over chaining and raising errors import

    scala.language.higherKinds import cats.MonadError def fetchVerifiedAccountAddress[F[_]](id: AccountId) (implicit M: MonadError[F, Err]): F[Account] = for { account <- fetchAccount(id) address <- if (!account.verified) M.raiseError(AccountNotVer else M.pure(account.address) } yield account.address
  22. Validation For everything that want to "come inside" we must

    verify invariants report all encountered errors We need error accumulation
  23. Limitations of flatMap All we can do using flatMap, is

    sequencing operations one after another, each subsequent operation being dependent on the previous ones. It is therefore impossible to achieve error accumulation in these contexts.
  24. Cats Validated This is the reason why cats introduces the

    Validated datatype: It is in fact isomorphic to Either, it di ers only by the operation we can perform on it. sealed trait Validated[+E, +A] case class Valid[+A](value: A) extends Validated[Nothing, A] case class Invalid[+E](error: E) extends Validated[E, Nothing]
  25. Composing validations "horizontally" with *> import cats.Validated import cats.implicits._ def

    ageIsPositive(age: Int): Validated[MyError, Int] = if (age >= 0) age.valid else MyError("age must be a positive number").invalid def mustBeMajorInTheUS(age: Int): Validated[MyError, Int] = if (age >= 21) age.valid else MyError(s"$age is underage").invalid def canEnterLiquorShop(age: Int) = ageIsPositive(age) *> mustBeMajorInTheUS(age)
  26. Composing validations "vertically" with mapN import cats.Validated import cats.implicits._ case

    class Customer(name: String, age: Int) def mustNotBeEmpty(string: String): Validated[MyError, String] = if (string.isEmpty) MyError("empty input").invalid else string.valid def validateCustomer(name: String, age: Int) = ( mustNotBeEmpty(name), mustBeMajorInTheUS(age) ).mapN(Customer.apply)
  27. Getting an instance of Applicative For Validated[E, ?] to have

    an instance of applicative, we need a Semigroup[E]. The Semigroup will precisely be used to accumulate the encountered errors.
  28. Sometimes there isn't a Semigroup There is no sensible way

    to combine two BusinessError into a "bigger" one; we cannot de ne a Semigroup[BusinessError]. sealed trait BusinessError case class MaxLengthExceeded(max: Int, actual: Int) extends Busi case class NumberParseError(inputString: String) extends Busi // etc...
  29. Introducing NonEmptyList For this kind of purposes (among many others),

    cats introduces the NonEmptyList data type. For any type A, there is an instance of Semigroup[NonEmptyList[A]]. import cats.data.NonEmptyList val someValues = NonEmptyList.of(1, 2, 3) val someMoreValues = someValues ++ List(4, 5)
  30. ValidatedNel It is such a widely used pattern that cats

    provides a type alias for it, as well as validNel and invalidNel syntaxes: type ValidatedNel[E, A] = Validated[NonEmptyList[E], A] 42.validNel "number should be positive".invalidNel
  31. Chaining validations with andThen We can also compose validations sequentially

    with andThen. def parseInt(input: String): Validated[MyError, Int] = Validated.fromTry(Try(input.toInt)).leftMap(t => MyError(t.get def parseAndValidateAge(input: String): Validated[MyError, Int] parseInt(input).andThen(mustBeMajorInTheUS(_))
  32. Validating collections Let say we have a validation function A

    => Validated[E, B]. We want to validate a List[A] in order to obtain a Validated[E, List[B]]? We cannot use map on the list as it would yield a List[Validated[E, B]].
  33. Using Traverse import cats.implicits._ def validationFunction(s: String): Validated[Err, Int] =

    ??? val list: List[String] = ??? val validatedList: Validated[Err, List[Int]] = list.traverse(validationFunction)
  34. Validation guidelines In summary, the guidelines for functional validation are

    quite simple: De ne small, single-purpose and reusable validations compose them using *>, andThen and mapN to form bigger ones
  35. Drawing the line between validation and errors Validation is about

    "failing loudly", but our mantra also told about "failing fast". def readFile(path: Path): Either[Errors, File] = ??? def parseConfig(file: File): Either[Errors, Config] = ??? def validateAppConf(config: Config): Validated[Errors, AppConfig def startApp(configFile: Path): Either[Error, App] = for { file <- readFile(configFile) conf <- parseConfig(file) appConf <- validateAppConf(conf).toEither } yield new App(appConf)
  36. Avoiding boilerplate... In more complex examples, going back and forth

    between Either and Validation can be cumbersome. It would be easier if we could stick to Either, but we'd need a way to combine Eithers in parallel as we do with Validated.
  37. ...with parMapN That's exactly what the Parallel typeclass gives us:

    The Parallel typeclass does the conversions for us under the hood! def validateAge(age: Int): Either[Errors, Int] = ??? def validateName(name: String): Either[Errors, Int] = ??? import cats.implicits._ // the following (validateAge(age), validateName(name)).parMapN(Customer.apply) // is equivalent to (validateAge(age).toValidated, validateName(name).toValidated).m