Functional Error Handling in Scala

Long Cao

Software Engineer @ MediaMath

What is error handling?

  • In Java, usually means exceptions and try/catch
  • Java has checked exceptions - compiler forces handling (pretty flawed, though)
  • Scala has only unchecked exceptions - compiler won't complain about it
class Coffee

def buyCoffee(money: Int): Coffee = {
  if (money < 3)
    throw new Exception(s"not enough money, need 3 dollars")
  else 
    new Coffee
}

What is functional error handling?

  • In Scala, that means using error handling types and passing in functions into their contexts
  • Referential transparency - replace expression with its value predictably​
    • Correctness and easier to understand quickly
    • Bonuses - compiler optimizations, memoization
class Coffee

// Not referentially transparent:
// Does this function always return a Coffee if it throws an exception?
def buyCoffee(money: Int): Coffee

What I hope you get out of this

  • Use error handling types to wrap over program flow: compiler guarantees
  • Don't throw exceptions, breaks referential transparency
  • If it flatMaps, "business logic" becomes functions that operate on your error handling monads - very testable, especially if stateless and pure
  • A practical application of functional error handling- you don't have to dive too far into the deep end or read/write byzantine code to reap the benefits

A vastly oversimplified explanation of 'monad'

  • A 'container' type that has predefined behavior
  • Allows for building computations - chain and compose of functions
  • It's a box!

So... how do we go about making it better?

How to improve using Option

  • Use Options to indicate found/not found states instead of throwing exceptions
    • Q: Do the types tell you anything about why it failed?
def buyCoffee(money: Int): Option[Coffee] = {
  if (money < 3)
    None
  else 
    Some(new Coffee)
}

scala> buyCoffee(2).flatMap(_ => Some("i got a coffee"))
res0: Option[String] = None

scala> buyCoffee(3).flatMap(_ => Some("i got a coffee"))
res2: Option[String] = Some(i got a coffee)

How to improve using Try

  • Represents success or failure states
  • Useful for mingling with/wrapping over exception-throwing code
import scala.util.{ Failure, Success, Try }

def buyCoffee(money: Int): Try[Coffee] = {
  Try {
    if (money < 3)
      throw new Exception(s"not enough money, need 3 dollars")
    else 
      new Coffee
  }
}

scala> buyCoffee(2)
res0: scala.util.Try[Coffee] = 
    Failure(java.lang.Exception: not enough money, need 3 dollars)

scala> buyCoffee(3)
res1: scala.util.Try[Coffee] = Success(Coffee@5e7cd6cc)

How to improve using Either

  • Disjoint union - return one of two wrapped types
    • By convention: right-biased, Left indicates error, Right: success
    • Can't flatMap over it out-of-the-box: not a monad
case class FailureReason(reason: String)

def buyCoffee(money: Int): Either[FailureReason, Coffee] = {
  if (money < 3)
    Left(FailureReason(s"not enough money, need 3 dollars"))
  else 
    Right(new Coffee)
}

scala> buyCoffee(2)
res0: scala.util.Either[FailureReason,Coffee] =
    Left(FailureReason(not enough money, need 3))

scala> buyCoffee(3)
res1: scala.util.Either[FailureReason,Coffee] =
    Right(Coffee@2a8448fa)

Library Alternatives

  • Disjoint Unions (similar to Either)
    • scalaz.\/
      • Right-biased disjunction
    • org.scalactic.Or
      • Left-biased
      • Reads as the "Good value Or Bad value"
    • cats.data.Xor
      • Right-biased disjunction

Common motif: Fail-fast error handling

  • Return the first error encountered in the flow when flatmapping over it
  • Kind of like an unhandled exception except the compiler will complain if you don't handle your cases
  • Most of these container types mentioned can be weaved together with for-comprehensions - will still fail fast!

For-comprehension with Or

import org.scalactic.{ Bad, Good, Or }

object CoffeeServiceOr {
  def purchaseCoffee(money: Int): Coffee Or FailureReason =
    for {
      beans <- buyBeans(money)
      coffee <- brewCoffee(beans)
    } yield coffee

  // fails without enough money
  def buyBeans(money: Int): Beans Or FailureReason = { ... }

  // fails 25% of the time to simulate a faulty grinder
  def brewCoffee(beans: Beans): Coffee Or FailureReason = { ... }
}

scala> CoffeeServiceOr.purchaseCoffee(2)
res0: org.scalactic.Or[Coffee,FailureReason] = 
    Bad(FailureReason(Not enough money to buy beans for a coffee, need 3))

scala> CoffeeServiceOr.purchaseCoffee(3)
res1: org.scalactic.Or[Coffee,FailureReason] = 
    Bad(FailureReason(Faulty grinder failed to grind beans!))

Caveats; things to think about

  • Mind your boundaries
    • Push final decision making with errors to the edges of your code
    • Be mindful of APIs exposed to clients - not everyone would know what to do with a scalaz.\/
  • Hard dependencies - scalaz, scalactic, & cats - be careful if your team isn't ready
  • Performance hits possible - lots of allocations, but measure before you conclude

Suggested Reading

  • Functional Programming in Scala - Chapter 4: "Handling errors without exceptions"

Questions?

Functional Error Handling in Scala

By longcao

Functional Error Handling in Scala

  • 1,602