Blog

Type-safe error handling with Scala 3

Xebia Background Header Wave

Introduction

In a previous blog post, we looked at type-safe error handling with Shapeless coproducts and realized that coproducts make up for the lack of union types in Scala 2.x. A coproduct can be seen as an EitherN to return either n types. In that regard, there’s no limit on the number of types we can use, in contrast to, for example, an Either[A, B].

The challenge with Shapeless coproducts is that although they work perfectly, the code is not as clean as it could be due to the lack of native support for union types. However, this changed in Scala 3! In this article, I will demonstrate that we can create the same typed error channel as in the previous blog post, but this time with way less code and improved readability!

Why a typed error channel in the first place?

A typed error channel explicitly shows the programmer what kind of errors can appear and thus immediately understands from the signature which cases he needs to handle. These handlers can become simple functions that are easy to test.

If we look at web development, several errors are guaranteed to happen:

  • Input validation errors; Should be mapped back to the user so that the person can correct his input.
  • Domain validation errors; Depending on the error, you might not want to notify the user itself but rather trigger an alert for a support employee to fix the issue.
  • Unexpected exceptions; Should be logged, trigger an alert, and you should inform the user that "something went wrong". For example, you wouldn’t want potential sensitive data leaking to the outside world.

Code example

Let’s have a look at the code:

import scala.io.StdIn.readLine

object DivideCommandLineApp extends App {
  case class ParseNumberError(value: String)
  case object DivideByZeroError

  def tryParse(s: String): Either[ParseNumberError, Double] =
    s.toDoubleOption.fold[Either[ParseNumberError, Double]](Left(ParseNumberError(s)))(a => Right(a))

  def tryDivide(a: Double, b: Double): Either[DivideByZeroError.type, Double] =
    if (b == 0) Left(DivideByZeroError)
    else Right(a / b)

  def tryRunApp: Either[ParseNumberError | DivideByZeroError.type, Double] = for {
    a <- tryParse(readLine)
    b <- tryParse(readLine)
    r <- tryDivide(a, b)
  } yield r

  tryRunApp.fold({
    case ParseNumberError(error) => println(s"Error: Input '$error' is not a number")
    case DivideByZeroError => println("Error: Cannot divide by zero")
  }, r => println(s"Result: $r"))
}

By leveraging the Either type, we do not need anything fancier. As Either already is a typed error channel, we can use a union type on the left side. It composes well, as seen in the tryRunApp method. It returns either a ParseNumberError or a DivideByZeroError.

Voilà! That’s how easily you can have error channels in Scala 3 with the help of union types.

Differences

Let’s have a quick objective look at the differences between the code that uses Shapeless coproducts and the code below:

  • Around 70% reduction of code (81 lines vs 24)
  • No need for an external library (i.e. Shapeless)
  • No need for implicits (not that I’m against implicits, but it can confuse new programmers in Scala quite a bit)

Conclusion

Using union types in Scala 3 brings new opportunities for modelling our domains and our ways of handling errors. There’s no need to use Shapeless coproducts for these simple union type constructions in Scala 3. Does this mean that Shapeless became obsolete? Of course not! There’s still a lot of functionality in Shapeless not covered by Scala 3.

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts