DEV Community

David Manwaring
David Manwaring

Posted on

Why Scala Made Me Stop Throwing Exceptions

Coming from the JavaScript ecosystem, I wanted to learn functional programming without drowning in unfamiliar syntax. Scala hit the sweet spot — familiar enough (JVM-based, C-style syntax) but genuinely functional. This is the first in a series exploring Scala’s most eye-opening features, starting with how it handles errors without throwing exceptions.

Imperative try/catch

Lets imagine we have a flakey API that we have to build against in Scala:

def flakeyApi(): String = {
  val randomIntBounded = Random.nextInt(100) // 0 to 99
  if(randomIntBounded < 50) throw new RuntimeException("A dummy failure")

  return "It worked!"
}
Enter fullscreen mode Exit fullscreen mode

An imperative try/catch as you expect looks like this in Scala

  val traditionalTry: String =
    try {
      flakeyApi()
    } catch {
      case error: RuntimeException => "it did not work, but I recovered here!"
      case _: Throwable            => "Something really bad happened"
    }
  println(traditionalTry)
Enter fullscreen mode Exit fullscreen mode

The value of traditionalTry is assigned either the return value of flakeyApi or one of the values in the catch block based on the type of exception that gets thrown.

Try Monad

Scala ships with a Try Monad. A Try Monad wraps a function and if the function succeeds, the return value will be Success if the function doesn’t throw, otherwise Failure Here’s a very simple example —

  val functionalTry: Try[String] = Try(flakeyApi())
    .recover {
      case error: RuntimeException => "it did not work, but I recovered here!"
      case _ => "Something really bad happened"
    }
Enter fullscreen mode Exit fullscreen mode

So why the need for the functional version at all? Well, throwing an exception is a considered a side effect since they can terminate a program all together, which in functional programming we don’t want because this means a function can produce a value outside of its return value and they also break referential transparency (A function that throws an exception can’t be replaced by its return value).

A nice feature of this style of handling failure is the composability you have:

  val functionalTry: Try[String] = Try(flakeyApi())
    .orElse(Try(lessFlakeyApi()))
    .map(successValue => successValue.toUpperCase)
    .flatMap(message => Try(aFullyWorkingApi(message)))
    .recover {
      case error: RuntimeException => "it did not work, but I recovered here!"
      case _ => "Something really bad happened"
    }
Enter fullscreen mode Exit fullscreen mode
  1. Try and get a value from flakeyApi
  2. If flakeyApi doesn’t return a successful value, try lessFlakeyApi (.orElse is a method of Try )
  3. Transform the success value of either flakeyApi or lessFlakeyApi to an upper case String
  4. Finally, flatMap the result with another Try

Either Monad

So the above logic is all well and good, but the question is — “Why would I produce code that has side effects because of exceptions, only to wrap my functions with Try function invocations?”

Well that answer is you wouldn’t, the above example is good for when you’re trying to make non functional API’s that you don’t have any control over functional. For instance, working with an underlying Java package that has no notion of returning failure as a function value.

Scala has a built in Monadic type called Either , similar to Try it
can contain one of two values — a “Left value” or a “Right value”,
making it a great choice for returning failure or success to a caller.

Here’s an example when you have control over the API that you’re building —

package example

case class Person(name: String, age: Int)
case class Grade(subject: String, grade: Int)

sealed trait PersonException
case class InvalidPersonException(message: String) extends PersonException

object ExampleApp extends App {
  def getGradesForPerson(person: Person): Either[PersonException, Grade] = person match {
    case Person("Joe Bloggs", _) => Right(Grade("English", 88))
    case Person("John Jones", _) => Right(Grade("Maths", 101))
    case Person(name, _) => Left(InvalidPersonException(s"Invalid person $name"))
  }

  val joeBloggs = Person("Joe Bloggs", 33)
  val johnJones = Person("John Jones", 29)
  val unknownPerson = Person("Will Smith", 33)

  getGradesForPerson(joeBloggs) // => Right(Grade(English,88))
  getGradesForPerson(johnJones) // => Right(Grade(Maths,101))
  getGradesForPerson(unknownPerson) // => Left(InvalidPersonException(Invalid person Will Smith))
}
Enter fullscreen mode Exit fullscreen mode

As you can see here just by reading the function signature for getGradesForPersonit can return an error or it can return a value. In the try/catch world you need to dig into the function body to see if exceptions can be thrown or not.

Similarly to Try, Either types also compose and there’s a few different flavours for how we can chain transformations from one Either type to the next, below is an example with .flatMap


val joeBloggs = Person("Joe Bloggs", 33)
val result = getGradesForPerson(joeBloggs)
  .flatMap(getGrade)
Enter fullscreen mode Exit fullscreen mode

flatMap is a method on Either and we can transform from one Either to another. Here we’re calling getGradesForPerson which is an Either[PersonException, Grade] and we want to end up with Either[GradeException, Int] . getGrade will only be called if the first Either returns Right . If it returns Left then result will look something like: Left(GradeException)

As you can probably sense, if there are multiple function calls in addition this, you could end up with some tricky to read flatMap chains. A cleaner and usually more preferred approach is to use a for-comprehension like this:

 val forResult = for {
    grades <- getGradesForPerson(joeBloggs)
    grade <- getGrade(grades)
    } yield grade
Enter fullscreen mode Exit fullscreen mode

Personally, I find the for comprehension the most readable. The for-comprehension is actually syntactic sugar and behind the scenes at compile time, this will translates to a flatMap like the previous example.

But there’s a gotcha …

The type of forResult is not Either[GradeException, Int] like you might expect because it’s the last function call in the series. It’s actually Either[Object, Int] and this caught me out so many times in the beginning when learning Scala. So let’s break this down:

  • The return value for getGradesForPerson is Either[PersonException, Grade]
  • We’re then flat mapping with getGrade which has a different error type. When I say type here, I mean the error traits we’ve defined: PersonException and GradeException
  • Scala sees that there are two potential types in play and depending on your Scala version will infer the type as Object or Product with Serializable

So — when you’re tech designing what your functions will accept as inputs and return as an output, be sure that you don’t confuse things by returning different error types. Here’s a revised version with a single error trait that our case classes can inherit:

sealed trait GradeException
case class UnknownSubjectException(message: String) extends GradeException
case class InvalidGradeException(message: String) extends GradeException
case class InvalidStudentException(message: String) extends GradeException

def getGradesForPerson(person: Person): Either[GradeException, Grade] =
  person match {
    case Person("Joe Bloggs", _) => Right(Grade("English", 88))
    case Person("John Jones", _) => Right(Grade("Maths", 101))
    case Person(name, _) =>
      Left(InvalidStudentException(s"Invalid person $name"))
  }

def getGrade(grade: Grade): Either[GradeException, Int] = grade match {
  case Grade(subject, age) if age > 100 =>
    Left(InvalidGradeException(s"$subject grade cannot be greater than 100"))
  case Grade(subject, age) if age < 0 =>
    Left(InvalidGradeException(s"$subject grade cannot be less than 0"))
  case Grade(_, grade) => Right(grade)
}

val johnJones = Person("John Jones", 33)
val maybeGrade: Either[GradeException, Int] = getGradesForPerson(johnJones)
    .flatMap(getGrade)
Enter fullscreen mode Exit fullscreen mode

Evaluating Either types

With Either types, we need a clean way of inspecting values to see if we’re handling a Left or Right value. For this, we can use a simple pattern match expression as follows:

    val result = getGradesForPerson(invalidPerson)
    .flatMap(getGrade)

  result match {
    case Left(error)  => println(error)
    case Right(grade) => println(s"Got result: $grade")
  }
Enter fullscreen mode Exit fullscreen mode

What catch blocks in try/catch really are

For completeness, if we go back to where we started and examine the try catch statement from before:

  val traditionalTry: String =
    try {
      flakeyApi()
    } catch {
      case error: RuntimeException => "it did not work, but I recovered here!"
      case _: Throwable            => "Something really bad happened"
    }
  println(traditionalTry)
Enter fullscreen mode Exit fullscreen mode

This catch statement is a pattern match expression which is exactly how we evaluated what type of value we were handling on the Either type.

Final thoughts

What sold me on this approach is transparency — function signatures that don’t lie. When I see Either[GradeException, Grade], I know exactly what can go wrong and how to handle it. Yes, wrapping everything in Try or Either adds verbosity. But I’ll take explicit error handling over mysterious RuntimeExceptions any day.

Top comments (0)