Every Argument for Static Typing Applies to Typed Errors

Lachlan O'DeaLachlan O'Dea
16 min read

This was originally published in February 2022 as a gist.


Think of all the arguments you've heard as to why static typing is desirable — every single one of those arguments applies equally well to using types to represent error conditions.

An odd thing I’ve observed about the Scala community is how many of its members believe that a) a language with a sophisticated static type system is very valuable; and b) that using types for error handling is basically a waste of time. If static types are useful—and if you like Scala, presumably you think they are—then using them to represent error conditions is also useful.

Here's a little secret of functional programming: errors aren't some special thing that operate under a different set of rules to everything else. Yes, there are a set of common patterns we group under the loose heading "error handling", but fundamentally we're just dealing with more values. Values that can have types associated with them. There's absolutely no reason why the benefits of static type checking apply to "success" values, but not "error" values. They're still just values.

An objection that might now be raised is that, sure, typed errors have benefits, but they have significant costs that make them not worth it, at least in Scala. This is a legitimate concern, but I think we have a good solution which I'll get in to (spoiler: bifunctors with covariant type parameters).

But first, let's look at why using static types to represent errors is useful. I'll mainly be comparing with the traditional untyped Throwable error channel used by Scala Future , Cats Effect IO and Monix Task. The complete script blocks will all be compilable with Scala CLI 0.1.0.

Example

Consider this code using Monix Task:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import java.nio.file.{Path, Paths}
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global
import java.io.FileNotFoundException

opaque type Data = String

def loadFromFile(file: Path): Task[Data] =
  // can fail with IOException
  Task.now("some file data")

val myDataFile = Path.of("myfile")

def loadData: Task[Data] = loadFromFile(myDataFile)

val defaultData: Data = "default"

def useData(data: Data): Task[Unit] = Task.unit

def program: Task[Unit] =
  loadData
    .onErrorRecover { case _: FileNotFoundException =>
      defaultData
    }
    .flatMap(data => useData(data))

program.runSyncUnsafe()

The loadFromFile method fails with FileNotFoundException if it cannot find the data file. It may potentially be called from a number of different places, which may have different error handling needs. In the case of program, we want to fall back to some default data if the file can't be found.

The first thing to consider here is: imagine when you're writing program above, how do you know what kinds of exceptions loadData is going to throw, and which one to catch? There's three possible approaches to figuring this out:

  • You read the error conditions in the Scaladoc or usage docs. In practice this is almost never specified in docs (and, if it’s worth putting error conditions in the documentation, why isn’t it worth putting them in the type?)

  • You dig into the implementation and figure it out. Fun.

  • You run the code and figure out what it actually throws. It's not hard to think of scenarios where this is extremely difficult to do.

These approaches are exactly what you'd do if programming in JavaScript or Ruby.

Now we need to make an improvement to our program, it needs to support loading the required data from a a database as well. Let's just have loadData call the appropriate implementation:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import java.nio.file.{Path, Paths}
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global
import java.io.FileNotFoundException

opaque type Data = String

def usingDb: Boolean = args.headOption.contains("db")

def loadFromDb: Task[Data] =
  // can fail with SQLException or NoSuchElementException
  Task.now("some sql data")

def loadFromFile(file: Path): Task[Data] =
  // can fail with IOException
  Task.now("some file data")

val myDataFile = Path.of("myfile")

def loadData: Task[Data] =
  if usingDb then loadFromDb else loadFromFile(myDataFile)

val defaultData: Data = "default"

def useData(data: Data): Task[Unit] = Task.unit

def program: Task[Unit] =
  loadData
    .onErrorRecover { case _: FileNotFoundException =>
      defaultData
    }
    .flatMap(data => useData(data))

program.runSyncUnsafe()

This compiles fine of course. The problem is that if the data is missing from the database, we want to fallback to the default data as we did when loading from a file. loadFromDb isn't going to throw FileNotFoundException in this case, so our recovery code to fallback to a default won't be applied. Instead program will fail with whatever exception loadFromDb throws when it can't find the data. Our powerful, expertly engineered compiler is rendered useless and we have to hope a developer notices or covered this with a unit test.

So that's one example, but literally any kind of scenario where using a type would be helpful in a success case can come up with an error case. I suspect we just tend to spend less time thinking about the error cases and are ok muddling along without error types.

Objections

I'll try to anticipate and answer the objections that might be raised to the above scenario. If you have one I haven't answered, please let me know!

Unit Tests Should Catch It

Sure unit tests could have found that bug. But you can say that about literally any use of a static type. If you think writing unit tests to check values have expected types is a good use of your time, why are you using Scala?

Just Catch Everything

If we just recover from any kind of failure, then the "no data in the database" scenario will now fallback to the default as well:

def program: Task[Unit] =
  loadFromFile.onErrorHandle {
    _ => defaultData
  }.flatMap(data => useData(data))

The problem is that now treats a problem like the database server being unreachable in exactly the same way as the data simply not being present. Maybe you want that, but it's not hard to imagine situations where you definitely do not want that.

In my experience, this problem of catching too broadly is very common with untyped errors. The problem in general terms is you end up treating very different failure modes in the same way. It's not uncommon to find the Throwable error channel of a Task or Future containing:

  • domain-specific errors of the application (eg "user not found")

  • unrecoverable failures (eg "can't connect to the database")

  • straight up bugs (eg calling .head on an empty list)

Handling the second two in the same way as the first is very bad: it obscures very real problems. Often the program will fail at a later point and it will take a great deal of work to trace the problem back to a bug that was treated like a normal domain error.

Use Domain Errors

Instead of relying on FileNotFoundException, which is specific to that implementation, we should introduce a NoDataException and have both the file and DB applications use that to indicate lack of data:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import java.nio.file.{Path, Paths}
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global
import java.io.FileNotFoundException
import java.util.NoSuchElementException

opaque type Data = String

final class NoDataException(t: Throwable) extends Exception(t)

def usingDb: Boolean = args.headOption.contains("db")

def loadFromDb: Task[Data] =
  // can fail with SQLException or NoSuchElementException
  Task.now("some sql data")

def loadFromFile(file: Path): Task[Data] =
  // can fail with IOException
  Task.now("some file data")

val myDataFile = Path.of("myfile")

def loadData: Task[Data] =
  if usingDb then
    loadFromDb.onErrorRecoverWith { case e: FileNotFoundException =>
      Task.raiseError(NoDataException(e))
    }
  else
    loadFromFile(myDataFile).onErrorRecoverWith {
      case e: NoSuchElementException => Task.raiseError(NoDataException(e))
    }

val defaultData: Data = "default"

def useData(data: Data): Task[Unit] = Task.unit

def program: Task[Unit] =
  loadData
    .onErrorRecover { case _: NoDataException =>
      defaultData
    }
    .flatMap(data => useData(data))

program.runSyncUnsafe()

Which is a great idea! Of course, you'll want to document these error conditions so developers know about them. But having done all this (valuable!) work, why not go just a little further and represent the NoDataException via the type system? Not much more work, but huge benefits.

While domain errors are great and worth doing, without a type they can be easily missed altogether, and they don't prevent the "overbroad catching" problem mentioned above, or other kinds of mistakes.

The Benefits Aren't Worth the Effort

Maybe a typed error would be useful in this scenario, but how often do these scenarios come up? Is it really worth the work of putting error types everywhere just to help with these cases? It's common in many programs that the vast majority of possible errors can't be recovered from, and all we can do is "crash", whatever that means for a particular program—for example, returning a 500 response. Why bother representing these errors with types when we'll just ignore them?

This is a good point. The good news is you can have useful typed errors for just those error conditions that are important to your program, and ignore the rest. If a function or module doesn't have any recoverable errors, you can leave out the error type altogether, meaning any failure is a fatal crash. Later I’ll show how to manage this with ZIO.

Implementation

As mentioned above, a covariant bifunctor is the best way to manage typed errors in Scala. But first, let's review the other options to see why. Note I have done my best to steel-man all the options, if I've missed an opportunity to do something in a better way, please let me know and issue a correction.

Scala 3.1 "Safer Exceptions"

Scala 3.1 has an experimental feature called safer exceptions that allows thrown exceptions to be checked by the compiler. Of course Java does this already, but the Scala innovation is this is done in a way that works very nicely with things like higher order functions.

This is a great feature for Scala, but from an FP point of view I don't find it interesting. Throwing an exception isn't referentially transparent, it's not an expression that produces a value that can be manipulated via our FP tools.

Either

In the rare case we want our effect values to have a typed error, why not just use the traditional typed error tool? Why not just use Task[Either[E, A]]?

Of course this works… at small scales. The scalability problem is simply the standard "using two monads at once produces a lot of boilerplate" problem.

Lets take the case where we only want our two varieties of "not found" error represented in the error value:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import java.nio.file.{Path, Paths}
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global
import java.io.*
import java.util.NoSuchElementException
import java.sql.SQLException

opaque type Data = String

final class NoDataException(t: Throwable) extends Exception(t)

def usingDb: Boolean = args.headOption.contains("db")

def loadFromDb: Task[Either[SQLException | NoSuchElementException, Data]] =
  // can fail with SQLException or NoSuchElementException
  Task.now(Right("some sql data"))

def loadFromFile(file: Path): Task[Either[IOException, Data]] =
  // can fail with IOException
  Task.now(Right("some file data"))

val myDataFile = Path.of("myfile")

def loadData: Task[Either[NoDataException, Data]] =
  if usingDb then
    loadFromDb.flatMap {
      _.left
        .map {
          case e: NoSuchElementException => Task.now(Left(NoDataException(e)))
          case e                         => Task.raiseError(e)
        }
        .map(v => Task.now(Right(v)))
        .merge
    }
  else
    loadFromFile(myDataFile).flatMap {
      _.left
        .map {
          case e: FileNotFoundException => Task.now(Left(NoDataException(e)))
          case e                        => Task.raiseError(e)
        }
        .map(v => Task.now(Right(v)))
        .merge
    }

val defaultData: Data = "default"

def useData(data: Data): Task[Unit] = Task.unit

def program: Task[Unit] =
  loadData
    .map(r => r.getOrElse(defaultData))
    .flatMap(data => useData(data))

program.runSyncUnsafe()

Ugh, that's awful, and we're only doing something very basic. This pattern where you map an Either[A, B] to Either[Task[X], Task[Y]], then have to convert that to Task[Either[X, Y]] comes up literally all the time, and you have to manually take care of it every time.

While this approach isn't practical, it does illustrate an important point about how typed errors can be used. Before we had our interesting errors mixed in with the fatal errors in the Task's error channel. Now we've extracted only the errors that are meaningful to our program into a type and left only the fatal problems in Task's untyped channel. For our program there's no meaningful way to recover from an SQLException, and you can see how in loadData we've moved it from a typed error to the untyped Task error channel.

I should note that if you don't have effects to worry about, then Either by itself is a great way to do typed errors. And do you know why? Because Either is a covariant bifunctor 😀

EitherT

But Haskel hath given us the solution to the multiple monad boilerplate problem!

haskell the shit out of this.jpeg

And Cats gives us a Scala version. Using EitherT[Task, E, A] , the boilerplate is gone:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import java.nio.file.{Path, Paths}
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global
import java.io.*
import java.util.NoSuchElementException
import java.sql.SQLException
import cats.data.EitherT
import cats.implicits.*

opaque type Data = String

final class NoDataException(t: Throwable) extends Exception(t)

def usingDb: Boolean = args.headOption.contains("db")

type TaskE[E, A] = EitherT[Task, E, A]

def loadFromDb: TaskE[SQLException | NoSuchElementException, Data] =
  // can fail with SQLException or NoSuchElementException
  EitherT.rightT("some sql data")

def loadFromFile(file: Path): TaskE[IOException, Data] =
  // can fail with IOException
  EitherT.rightT("some file data")

val myDataFile = Path.of("myfile")

def loadData: TaskE[NoDataException, Data] =
  if usingDb then
    loadFromDb.leftFlatMap {
      case e: NoSuchElementException => EitherT.leftT(NoDataException(e))
      case e                         => EitherT(Task.raiseError(e))
    }
  else
    loadFromFile(myDataFile).leftFlatMap {
      case e: FileNotFoundException => EitherT.leftT(NoDataException(e))
      case e                        => EitherT(Task.raiseError(e))
    }

val defaultData: Data = "default"

def useData(data: Data): Task[Unit] = Task.unit

def program: Task[Unit] =
  loadData
    .getOrElse(defaultData)
    .flatMap(data => useData(data))

program.runSyncUnsafe()

But this suffers from a general problem with Monad transformers in Scala: type inference suffers. While Monad transformers are more flexible than a bifunctor, this tends to make them more difficult to use and slower to execute.

But for me the big problem is that all the types in Cats EitherT are invariant. This introduces a new kind of scalability problem with regard to error handling. Let's say we want to use a type hierarchy to represent our errors—not the only way to do it, but certainly not unusual in Scala. This won't work very well if you want to take advantage of more specific subtypes:

//> using scala "3.1.0"
//> using lib "io.monix::monix-eval:3.4.0"

import cats.data.EitherT
import cats.implicits.*
import monix.eval.Task
import monix.execution.Scheduler.Implicits.global

sealed trait MyError
case class ErrorA(code: Int) extends MyError
case class ErrorB(message: String) extends MyError

type TaskE[A, B] = EitherT[Task, A, B]

def a: TaskE[ErrorA, Unit] = EitherT.leftT(ErrorA(42))

def b: TaskE[ErrorB, Unit] = EitherT.leftT(ErrorB("💣"))

def aAndB: TaskE[MyError, Unit] = a >> b

val program = aAndB
  .leftSemiflatMap((e: MyError) => Task(println(s"Got error: $e")))
  .merge

program.runSyncUnsafe()

This won’t compile:

[error] ./eithert-invariant.sc:19:40: Found:    eithert-invariant.TaskE[eithert-invariant.ErrorB, Unit]
[error] Required: eithert-invariant.TaskE[eithert-invariant.ErrorA, B]
[error] 
[error] where:    B is a type variable with constraint 
[error] def aAndB: TaskE[MyError, Unit] = a >> b
[error]                                        ^

The invariance of the type error type in EitherT prevents using the supertype of the two errors. We really want the error type to be covariant, so why not just change EitherT to use covariance? In most cases, Cats uses invariant type parameters, and there's good reasons for that. Some solutions that work well in Haskell—which does not have subtyping—do not work well with variance.

Edited to use leftWiden, the original used widen and swap:

Because sub-typing is such a basic feature of Scala, many Cats types have a feature that their Haskell equivalents don’t need: widen, which allows us to manually widen type parameters when needed. However in the case of EitherT, widen would apply to the success type, not the error type, so we need leftWiden:

def aAndB: TaskE[MyError, Unit] = a.leftWiden >> b.leftWiden

So, yeah, we can make monad transformers work for typed errors, but in my opinion a covariant bifunctor provides much better ergonomics in Scala.

Covariant Bifunctor

The ZIO IO[+E, +A] type, it's Either[+A, +B] with effects!

Here’s the ZIO equivalent of the EitherT code:

//> using scala "3.1.0"
//> using lib "dev.zio::zio:1.0.12"

import java.nio.file.{Path, Paths}
import zio.*
import java.io.*
import java.util.NoSuchElementException
import java.sql.SQLException

opaque type Data = String

final class NoDataException(t: Throwable) extends Exception(t)

def usingDb: Boolean = args.headOption.contains("db")

def loadFromDb: IO[SQLException | NoSuchElementException, Data] =
  // can fail with SQLException or NoSuchElementException
  IO.succeed("some sql data")

def loadFromFile(file: Path): IO[IOException, Data] =
  // can fail with IOException
  IO.succeed("some file data")

val myDataFile = Path.of("myfile")

def loadData: IO[NoDataException, Data] =
  if usingDb then
    loadFromDb.refineOrDie { case e: NoSuchElementException =>
      NoDataException(e)
    }
  else
    loadFromFile(myDataFile).refineOrDie { case e: FileNotFoundException =>
      NoDataException(e)
    }

val defaultData: Data = "default"

def useData(data: Data): IO[Nothing, Unit] = IO.unit

def program: IO[Nothing, Unit] =
  loadData
    .orElseSucceed(defaultData)
    .flatMap(data => useData(data))

Runtime.default.unsafeRun(program)

Pretty nice, I think. And thanks to covariance, no more widen shenanigans are needed to deal with subtypes:

//> using scala "3.1.0"
//> using lib "dev.zio::zio:1.0.12"

import zio.*

sealed trait MyError
case class ErrorA(code: Int) extends MyError
case class ErrorB(message: String) extends MyError

def a: IO[ErrorA, Unit] = IO.fail(ErrorA(42))

def b: IO[ErrorB, Unit] = IO.fail(ErrorB("💣"))

def aAndB: IO[MyError, Unit] = a *> b

val program = aAndB.catchAll(e => IO.effectTotal(println(s"Got error: $e")))

Runtime.default.unsafeRun(program)

The Importance of Error Refinement

In the first ZIO example above, you can see the use of the refineOrDie method. This method (and its variants) are very useful tools for managing errors, I use them all the time. It's worth digging in to exactly what they're doing.

ZIO's Error Model

In ZIO there are essentially two “channels” for error conditions:

  • The typed "failure" channel, the use of which this post is about

  • The untyped “defect” channel, which always carries some instance of Throwable

The type IO[Nothing, Foo] is describing an effect where there are no failures the user of the effect needs to deal with. It “can't fail”. Of course, anything can fail as we're all well aware, either due to bugs or lack of resources. So what happens when your "can't fail" IO[Nothing, Foo] effect runs out of memory? The effect's execution terminates with an OutOfMemoryError in the "defect” channel. There’s usually no point in representing conditions like out of memory in the type system—they can happen literally anywhere and can’t be recovered from or handled.

ZIO offers a bunch of methods for dealing with both failures and defects. The naming convention works like this:

  • Methods that fail with a typed error have fail in their name

  • Methods that raise a defect have die in their name

  • catch methods catch typed failures

  • catchDefect methods catch defect Throwable s

Note that you can catch and deal with defects in ZIO, but generally this is only used for diagnostic and reporting purposes, rather than actual error handling, as it loses all the type-checking capabilities I’ve just outlined.

Another note: I’ve oversimplified a bit here. ZIO’s entire error handling infrastructure is very robust, and an effect can actually generate multiple defects (for example, when resource cleanup fails), and ZIO allows you to inspect them all.

Final note: the terminology die comes from the fact that unless caught, a defect will propagate up and kill the current fiber, which is the ZIO equivalent of "crashing with a stack trace”.

Refining The Error Type

Now finally we can cover error refinement, which is moving some or all of the typed failures of an effect into the untyped defect channel (it is possible to go the other way too, but this is not generally useful).

Refinement narrows the error type to some subset of errors that you actually care about in that section of code, making the failures you don’t care about defects. Example:

val effect: IO[Throwable, Unit] = ???
val refined: IO[IOException, Unit] = effect.refineToOrDie[IOException]

If refined were to hit, say, an ArrayIndexOutOfBoundsException, that would now be raised as a defect. What we’re saying here is that for our program, it only makes sense to recover or deal with IOExceptions.

Refinement is important because which errors are considered “interesting” and worthy of expressing as types can vary according to the layer you’re operating at. Imagine you are writing an SQL library. SQL operations can fail in many different ways, but as a library author you can’t say how different applications will want to handle all the different cases. It makes sense for your library’s API to put all the SQL errors in the type, like IO[SQLException, A]. Users of the library now know what kind of errors to expect from your library.

But in many applications there’s no meaningful way to recover from many kinds of database errors. With refinement, you can just reclassify whatever you consider unrecoverable as defects and deal only with what makes sense for your app. For example, you might want to only deal with transient SQL errors:

val sqlEffect: IO[SQLTransientException, String] = sqlLibrary.operation.refineToOrDie[SQLTransientException]

Now if we decide to retry failures, we don’t end up pointlessly retrying things it doesn’t make sense to retry:

// transient exceptions will be retried 5 times
// other SQL exceptions are raised as defects and not retried
sqlEffect.retryN(5)

There’s other variants of “refine or die” methods that allow for more sophisticated refinement. There’s also the orDie method that renders all failures as defects, setting the error type to Nothing, for situations where any of the typed failures can be considered fatal.

Conclusion

While error handling has some unique coding patterns, for a functional programmer error values aren’t fundamentally different from success values. We should seek to apply our FP tools, including types, to dealing with errors. For Scala, a covariant bifunctor is the best tool I’ve found for error handling.

Please let me know about any mistakes I’ve made or anything I’ve missed on this topic.

5
Subscribe to my newsletter

Read articles from Lachlan O'Dea directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lachlan O'Dea
Lachlan O'Dea

I am a Scala developer from Melbourne Australia.