Error Handling in Scala – A Braindump – The Java Way



Error Handling in Scala – A Braindump – The Java Way

0 0


scala-error-handling

Slides for "Scala Error Handling" presentation

On Github jmccance / scala-error-handling

Error Handling in Scala

A Braindump

Created by Joel McCance

The Java Way

Exceptions

  • If something goes wrong, throw an exception.
  • Use checked exceptions to force handling of certain errors.

Exceptions

  • Invisible to the type system.
  • Inflexible: Only one way to throw and catch an exception.
  • Inefficient: Don't always need/want the stack trace.

nulls

  • Used when a value is undefined or not found.

nulls

"My billion-dollar mistake." — Tony Hoare

  • The NullPointerException landmine
  • When can you pass null? When can't you? When do you get one back?

¯\_(ツ)_/¯

The Scala Way

Encode errors into types

  • The type communicates what the user can expect.
  • Error types provide combinators to separate main code from error-handling cleanly and efficiently.

Benefits

  • Always clear what you're passing in and what you're getting back.
  • More powerful, concise tools for combining results.
  • Compiler enforces that you handle errors appropriately.
  • Can compose error types.

Scala Standard Library

Scala Standard Library

  • Option[A]: "An A, which might not exist"
  • Try[A]: "An A or an exception"
  • Either[E, A]: "An A or an error E"
  • Future[A]: "Eventually an A, or an exception"

Option

  • An Option[A] is either None or Some[A].
  • Useful for functions that need to return a "not found" response, or if the value of the function for that input is undefined.
  • Handy for dealing with Java APIs that return null

Option

// Converts nulls to None and non-nulls to Some
val oRecord = Option(javaService.getRecord(id))

oRecord match {
  case Some(record) => Ok(record)
  case None => NotFound
}

Try

  • A Try[A] is either a Success[A] or a Failure.
  • Failure contain the exception that was thrown.
  • Another handy tool for interacting with Java APIs

Try

Try(doSomethingDangerous()) match {
  case Success(record) => Ok(record)
  case Failure(t) => InternalServerError(t.getMessage)
}

Either

  • An Either[A, B] is either Left[A] or Right[B]
  • Like Try, but with an error of any type you want
  • Left is the "error" value by convention
  • Useful when you want to return the error of the first operation that failed in a sequence of operations
  • If you can use it, Scalactic's Or type is better. (Stay tuned!)

Either

getRecord(id) match {
  case Right(record) => Ok(record)
  case Left(error) => BadRequest(error)
}

Future

  • A Future[A] represents an "eventual" value.
  • When a Future is complete, its value is a Try.

Future

Unlike other types, you pretty much never unpack a Future yourself.

getRecord(id)
  .map(record => Ok(record))
  .recover {
    case t => InternalServerError(t)
  }

Standard Combinators

An Interlude

Common Patterns

  • "If it's not an error, I want to do something."
  • "If it's not an error, I want to do something that might itself return an error."
  • "I want to run some validation on a non-error and convert it into an error if it fails."

Common Solutions

Given a type M[A]...

  • def map[B](f: A => B): M[B]
  • def flatMap[B](f: A => M[B]): M[B]
  • def filter(p: A => Boolean): M[A]

Example: Option#map

Transform a non-error value.

// Maybe get the record, maybe get nothing
val oRecord = getRecord(id) // def getRecord(): Option[Record]

// If we get the record, return it with a 200 OK, otherwise
// return a 404 NOT FOUND.
oRecord.map { record =>
  Ok(record)
}.getOrElse(NotFound("No such record: $id"))

Example: Option#flatMap

Combine the non-error value with another error value. (For example, chaining multiple service calls.)

getRecord(id1).flatMap { record1 =>
  getRecord(id2).map { record2 =>
    Ok(Seq(record1, record2))
  }
}.getOrElse(NotFound(s"No such records: $id1, $id2))

Example: Option#filter

Convert non-errors to errors.

getRecord(id)
  .filter(_.name == expectedName)
  .map(Ok(_))
  .getOrElse(
    NotFound(s"Record $id did not have expected name $expectedName"))

(The semantics of filter will vary from type to type.)

for-comprehensions

for-comprehensions

Using map and flatMap can result in deep nesting.

authenticateUser(credentials).flatMap { user =>
  getRecord(id).flatMap { record =>
    censorRecord(record).map { censoredRecord =>
      Ok(censoredRecord)
    }
  }
}

for-comprehensions

Scala provides some syntactic sugar to clean this up:

for {
  user ← authenticateUser(credentials) // flatMap
  record ← getRecord(id) // flatMap
  censoredRecord ← censorRecord(record) // map
} yield Ok(censoredRecord)

for-comprehensions

// as.foreach(a => f(a))
for (a ← as) { // no yield
  f(a)
}

// as.map(a => f(a))
val bs =
  for {
    a ← as // One "generator" line
  } yield f(a)

// as.flatMap(a => a.map(a2 => f(a2)))
val bs =
  for {
    a ← as // Multiple generator lines
    a2 ← a // All but the last will be flatMap, last will be map
  } yield f(a2)

for-comprehensions

  • Work on any type that provides the necessary method(s)
  • No yield and any number of generators ⇒ foreach
  • One "generator" line ⇒ map
  • Multiple generator lines ⇒ flatMap, flatMap, ..., map
  • Right-hand side of all generators must return the same type.
    • All Option[_], all Future[_], etc.
    • Contained type may change (Option[A] ⇒ Option[B], etc.)

for-comprehensions

Kitchen-sink example

for {
  user ← authenticate(credentials)
  record ← getRecord(id) if user.hasPermission(ViewRecords)
  accessTime = DateTime.now()
  censoredRecord ← censorRecordForUser(user, dateTime, record)
} yield {
  logAccess(accessTime, user, record)
  Ok(censoredRecord)
}

Scalactic Or, One, and Every

A Better Either

Scalactic Or

The Problem with Either

// Sample method definition
def foo(): Either[ErrorMessage, Foo]

for {
  a ← foo().right // I am not repeating myself!
  b ← bar(b).right // I am not repeating myself!
  c ← baz(c).right // Oh god, I'm repeating myself.
} yield (a, b, c)

Scalactic Or

  • An Or[A, E] is either a Good[A] or Bad[E].
  • Odd feature of Scala: Can write "Or[A, B]" as "A Or B" (infix notation).
  • "Left-biased": Can call map and friends directly without specifying you want to map over the "Good" case.

Scalactic Or

// Sample method definition
// Very readable
def foo(): Foo Or ErrorMessage

// Less noise
for {
  a ← foo()
  b ← bar(b)
  c ← bac(c)
} yield (a, b, c)

Scalactic Or

Either only accumulates the first error:

val errorOrResult =
  for {
    a ← foo().right
    b ← bar(b).right
    c ← baz(c).right
  } yield (a, b, c)

// Only logs the first error that occurred in the sequence.
errorOrResult.left.foreach { e =>
  log.error(e)
}

Scalactic Or

import org.scalactic.Accumulation._

// Declare methods to return One[ErrorMessage]
def validName(name: String): Name Or One[ErrorMessage]
def validAge(age: Int): Age Or One[ErrorMessage]

// Use one of the Accumulation combinators:
val personOrErrors: Person Or Every[ErrorMessage] =
  withGood(validName(name), validAge(age)) { (name, age) =>
    Person(name, age)
  }

personOrErrors.badMap { errors =>
  errors.foreach { error =>
    log.error(error)
  }
}

Best Practices

Best Practices: Option

  • Only use Option for "no such value" or "undefined" use-cases, or for wrapping Java APIs.
    • If there's an error message, you should return it.
  • Avoid using Option#get
    • NoSuchElementException is no better than an NPE.
    • Use combinators, match, and getOrElse instead.

Best Practices: Try

  • Use Try when dealing with APIs that throw exceptions, but convert the result to other error types for your own API.

Best Practices: Either/Or

  • If you can, use Or.
    • Be consistent and use one or the other throughout the project.
  • Develop a common type to use for the Left/Bad case to use throughout the project for easier composition of Eithers/Ors.

Best Practices: Future

  • Reserve failing the Future for lower-level errors, like connectivity errors.
    • Compose with other types for business-level errors.

Best Practices: Error Composition

  • Compose these types in the order you'll likely process them.
  • Exceptions: Future, Try
  • Errors: Or, Either
  • "Null-ness": Option
  • 👍: Future[Option[Person] Or ErrorMessage]
    • Handles async code, then errors, then the actual Person.
  • 👎: Option[Future[Person]]
    • Can't compose with other async code without first unwrapping the Future.

Best Practices: Exceptions

Best Practices: Grab Bag

Questions?

Error Handling in Scala A Braindump Created by Joel McCance