Let's imagine the prototype of a simple search function. For example, searching for a user login by its identifier in the storage:
def findLogin(id: Long): String
Seem easy, doesn't it? But what happens if there is no such identifier in the database? Will an empty string be returned? Or will an exception be thrown? Or maybe null will be returned instead of a string? All these questions might force us to get into the implementation of the function to figure out what is actually happening there.
Scala has a powerful compiler and standard library that will allow us to forget about these issues. Let's get familiar with visualizing error cases and never use the implicit null return!
Option
Let's make the return type more obvious with the Option wrapping:
def findLogin(id: Long): Option[String]
Option[A] is a container for zero or one element of a given type. Option[A] can be either a Some[A] or None object, which represents a missing value. In our method findLogin, we will produce Some(login) if a value of the login has been found, or None if our search has failed.
Then we can customize the result processing logic, for example, with pattern matching:
findLogin(id) match {
case Some(login) => s"Hi, $login!"
case None => "Please, sign up"
}
Obviously, the function may not find a value, but we'll be forced to handle this case, otherwise, the code will simply not compile!
Either
But what if we have different types of errors? For example, NoUser, when no user is found by the provided id, or NoLoginForUser, when the user id exists, but they haven't performed a login yet.
Sometimes it is very important to know what exactly led to the error, so it is easier to understand, fix, or prevent it. That is how Either can help us sort the errors out.
sealed trait ApiError
case class NoUser(id: Long) extends ApiError
case class NoLoginForUser(id: Long) extends ApiError
And now our function can return String or one of the error types we have defined:
def findLogin(id: Long): Either[ApiError, String]
Now we can give more specific advice to the user:
findLogin(id) match {
case Right(login) => s"Hi, $login!"
case Left(error) => error match
case NoUser(_) => "Please, sign up"
case NoLoginForUser(id) => s"Please, set up a new login in your profile for your id $id"
}Combining Option and Either
The combination of Option and Either can be useful for error handling when you want to execute some code only if the result is successful and return an error if the result contains an error.
Let's imagine that we have Option[String] with the user's name and Either[Error, Int] with their age:
val nameOpt: Option[String] = Some("John")
val validatedAge: Either[Error, Int] = Right(24)
Also, we have a feature that checks if the service is accessible by name and age:
def checkAccessFor(name: String, age: Int): Boolean
The following code will only call access checking if all necessary data is provided, otherwise, the getOrElse method on the Option type will return false:
val access =
(for
name <- nameOpt
age <- validatedAge.toOption // we convert Either to Option ignoring exact Error type
access = checkAccessFor(name, age)
yield access).getOrElse(false)Error handling everywhere
Now let's look closely at some methods for error handling from the standard library:
-
getOrElsecan be called onEitherorOptionand returns a clean value if it isRight[A]orSome[A], otherwise, it returns the provided default value:
val maybeGreeting: Option[String] = Some("Hello, Scala 3!")
val greeting1: String = maybeGreeting.getOrElse("Hello, world!") // Hello, Scala 3!
val validatedGreeting: Either[String, String] = Left("Parsing Error!")
val greeting2: String = validatedGreeting.getOrElse("Hello, world!") // Hello, world!
-
foldis also available for both types. It allows you not only to return a value but also to perform a function on it immediately. In the case ofOptionforNone, we just need to provide the default value. As forEither, we can also provide a function that can handle the error:
val configDelayOpt: Option[Int] = Some(3)
val delay1: FiniteDuration = configDelayOpt.fold(1.minute) { delay =>
FiniteDuration(delay, TimeUnit.MINUTES)
} // 3 minutes
val configDelayParsed: Either[String, Int] = Left("too big value")
val delay2: FiniteDuration = configDelayParsed.fold(
error => if error.contains("too big value") then 10.minutes else 0.minute,
delay => FiniteDuration(delay, TimeUnit.MINUTES)
) // 10 minutes
- There are many other methods on these types that are worth looking into, such as
filter,flatMap,zip,contains,orElse. Many of them may remind collections likeListorVector, and not by coincidence. In many cases, we can think ofOptionandEitheras a collection that consists of one or zero elements.
Conclusion
Now you know how Scala helps you avoid implicit error throws or null pointer returns. You've learned how to use Option and Either so that the compiler itself monitors the handling and actively helps us in detecting errors. You've also learned how exactly we can handle errors with a number of methods that are already defined in the standard library. Time to practice!