8 minutes read

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:

  • getOrElse can be called on Either or Option and returns a clean value if it is Right[A] or Some[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!
  • fold is 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 of Option for None, we just need to provide the default value. As for Either, 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 like List or Vector, and not by coincidence. In many cases, we can think of Option and Either as 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!

How did you like the theory?
Report a typo