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?
¯\_(ツ)_/¯
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
-
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
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: 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
Error Handling in Scala
A Braindump
Created by Joel McCance