DEV Community

Cover image for [Advanced] Error handling in FP
Zelenya
Zelenya

Posted on

[Advanced] Error handling in FP

Disclaimer: This topic is not really “advanced”, but it’s not something you should stress about if you’re a beginner. It’s advanced relatively to the rest of the course.

The fun fact about error handling is that nobody has figured it out. Not just in FP.

I change my mind every year, sometimes a few times a day. Regardless, we’ll talk about errors, where to find them, what works and what not, and then we’ll review two techniques I’ve been using recently (and don’t hate yet).

On errors

First, what do we mean by errors/exceptions/failures? Instead of definitions, let’s look at their causes, severity, and usage (where do they come from and where do they go).

Error causes

An error in runtime is either a programmer mistake or an actual error (some exceptional behavior outside of the happy-path).

Whenever you have code or behavior that “should never happen”, but one day it does — it’s a programmer mistake. If some function has an invariant or precondition (like the list of orders should be sorted, the principal amount shouldn’t be negative, or the date shouldn’t be from the future), and it gets violated — it’s a programmer mistake. Arguably, even if something “extraordinary” like stack overflow or out of memory happens, it’s still a programmer mistake — someone didn’t build or orchestrate the service/application properly.

These errors are not something we want to correct or react to — these are something we should prevent in the first place. Remember when we talked about getting the first element or the sum of an empty list? Having an explicit non-empty list prevents us from even trying that.

If we can’t prevent this (using precise types, tools, or code-reviews), we should crash the program as soon as possible (using whatever the language provides). We shouldn’t continue with inconsistent and potentially dangerous state or data.

💡 For example, we like how Unison provides the function called bug that aborts a program, which makes the intention more clear:

bug (Generic.failure "This should not happen! FE passed no ids!" ids)

🤔 Yes. Sometimes, it’s easier and cleaner to leave a good comment instead of overcomplicating and overdefencing the code. Tradeoffs.

Classic errors come from the external world (outside the programmer’s control). Imagine reading from a file or a database: if a file doesn’t exist or a database is not reachable, we should handle this failure because there isn’t much we can do to prevent these.

The line between the two isn’t always clean. Imagine we have a contact with another team, and one day, they send us a json with an unexpectedly empty list. Is treating this empty list as a non-empty list a programming mistake or an actual error/exception? Not easy to tell.

Error severity

We can look at errors (or failures) from another (controversial) perspective — based on their severity — some are recoverable, and some are non-recoverable.

If you are reading some data from a file and it’s empty, this might be recoverable — for example, we can have fallback data, a codepath for missing data, or alternative sources of data.

But if we’re failing to read the file we just wrote to, the file storage is unreachable, or the connection is unstable — there is nothing we can do. These exceptions are non-recoverable. Same for the difference between an empty database result set and a database being unreachable.

🤔  If we don’t treat it as a programming error, an error like stack-overflow or out-of-memory is very much non-recoverable.

The line between the recoverable and not is even blurrier, because even a crucial missing file can have a fallback.

Error purpose

It might be more valuable to look at the error purpose. Sometimes, we use errors for control flow (when we decide what to do); otherwise, we report them and — if there is nothing we can do about them — either throw them away or crash the app.

When we write code that does something depending on the failure, we want to know exactly what happened. So, for control flow, we want to have explicit proper types. For example, in the case of reading the file, we might want to distinguish between missing and empty files (and include different contexts):

enum MyFileError {
    MissingFile FileName,
    EmptyFile,
}
Enter fullscreen mode Exit fullscreen mode

Then we can decide. If the file is missing, regenerate it or fail; if it’s empty, use some default value…

match example 
  MissingFile name -> ...
  EmptyFile -> ... 
Enter fullscreen mode Exit fullscreen mode

💡 Using errors/exceptions for control flow is more controversial. It doesn’t help that errors and exceptions are different across languages. We’ll talk about it more later.

Otherwise, if the situation is truly exceptional, we want to report it. So, we (or our system) can react right away or do something to let us investigate it later. In this case, another error differentiator is the error's final consumers: programmers vs. external users.

If it’s an actual failure, programmers should get as much context as possible. We have access to the internals and are the ones who have to deal with this — so make it easier for our future selves. Make logs, traces, metrics, or whatever we can. If there is nothing a developer needs to manually do, and the infrastructure or retry mechanisms take care of the error, maybe bumping an error counter is enough (no need to spam the logs).

If the error happens at the edge of the program (or gets propagated there), we should only report the minimum required information for them to retry and optionally adjust their behavior. The user doesn’t need to know that our database is not doing well, but they need to know if they provided a malformed input and need to fix it.

💡 And we shouldn’t forget that there is a difference between a human interacting with our programs manually and programmatically via APIs.

If we squint, we can say that we use recoverable errors for control flow and not-recoverable errors we report. However, some recoverable errors are too innocent (or neutral, or useless) and don’t really affect the control flow. For example, if we know that we’re dealing with questionable data sources with rare useless samples full of nulls or trash data, we might want to just log and skip it (and keep doing what we’re doing).

😉 If you have logs full of warnings that nobody ever looks at, you know what we mean.

Also, there are some not-recoverable errors that you can’t report — like, if you’re already out of memory, you don’t have the luxury of making a pretty error report.

On error communication and ergonomics

The next question: How do we encode or model errors in the code? It certainly differs from language to language, but there are two main ways:

  • With Typed errors (when we use proper types and values to model errors)
  • With Exceptions (when errors don’t appear in the types and we use a built-in exception mechanism)

💡 Not really standardized terminology, just something we use here for convenience. There are different variations and syntaxes around these techniques.

Mainly comes down to the trade-off between being safe and explicit vs. being ergonomic (easy to use).

Typed errors

We talked a lot about typed errors in other lessons, but here're reminders.

We can use Optional to model missing values; for example, a function that extracts an integer from a string can return an optional integer because not every string contains integers:

fn extract_int(input: String) -> Option<Int>
Enter fullscreen mode Exit fullscreen mode

Or, we can use Result (or Either) to model more cases of failure. So, for example, when we have a Result<String, MyFileError>, we have to handle all the cases:

match example 
  Ok(file_content) -> ...
  Err(MissingFile name) -> ...
  Err(EmptyFile) -> ... 
Enter fullscreen mode Exit fullscreen mode

And so on. When we use typed errors, the type system tells us which cases we need to handle — we don’t need to worry about missing errors or handling errors we’ve already handled.

The annoying problem with typed errors is composition.

Error composition

When we end up with more than one error in one function, we have to deal with all of them. Remember we talked about parsing integers and doing division?

fn broken_div(x: String, y: String) -> Result<i32, ???> { // What is the result?
  let parsedX: i32 = x.parse()?; // Result<i32, ParseIntError>
  let parsedY: i32 = y.parse()?; // Result<i32, ParseIntError>
  cheeky_div(parsedX, parsedY)   // Result<i32, DivisionError>
}

fn parse(num: String) -> Result<i32, ParseIntError>

fn cheeky_div(x: i32, y: i32) -> Result<i32, DivisionError>
Enter fullscreen mode Exit fullscreen mode

The parsing can fail with ParseIntError, and the division can fail with DivisionError. The question is: what’s the broken_div function can fail with? Both? Either? How do we express this?

Last time, we simply converted each error into a String. This technically solves the problem (the result becomes Result<i32, String>), but then we sacrifice all the error information — a String is hard to use for control flow as well as for reporting.

We can use union types (or something similar, like polymorphic variants), which is less annoying but less supported. With union types, specifying the common type is as easy as:

 Result<i32, ParseIntError | DivisionError> 
Enter fullscreen mode Exit fullscreen mode

When that is not an option, we can make custom hierarchies of errors using ADTs (shouldn’t be anything new, we just build on top of what we already have):

enum MyDivError {
    ParseErr ParseIntError,
    DivErr DivisionError,
    AnotherErr String
}

Result<i32, MyDivError>
Enter fullscreen mode Exit fullscreen mode

Some people prefer multiple hierarchies and convert between them, others prefer one hierarchy for the whole app. We don’t need to go into more details.

Making all the hierarchies could be boring, verbose, and clumsy. For these reasons, some prefer using built-in error types (or hierarchies). Because many languages come with some extensible exception mechanism, we can use the main/root/parent error/exception (e.g., Throwable, SomeException, Error) as a common denominator:

 Result<i32, Throwable> 
Enter fullscreen mode Exit fullscreen mode

This kind of composition can destroys the specifics of an error and loose the whole point of typed errors.

Exceptions

Exceptions (or untyped errors), on the other hand, do not appear in types, which is their strength and weakness.

It’s easier to compose functions with exceptions, because we don’t even “see the errors”. At the same time, it’s easy to forget to handle them. When you catch exceptions, you have to know what to catch (otherwise, you get something generic, like Throwable, SomeException, or Error). It’s also easy to implement handlers for errors that never happen and errors that have already been handled (the type system can’t help you).

💡 Note for Scala developers and alike, when we talk about exceptions we also mean exceptions as part of IO or Task.

The nice thing about exceptions is that we can focus on the happy-path of the program:

fn magical_div(x: String, y: String) -> i32 {
  x.parse() / y.parse()
}
Enter fullscreen mode Exit fullscreen mode

Nice, but dangerous. Hopefully, the caller doesn’t forget to handle all the errors for me!

A note on performance

There are two camps:

  • One says that using exceptions is good for performance because they can be compiled into efficient machine code.
  • The other says that exceptions are expensive and shouldn’t be used for flow control, because walking the stack and making stack-traces isn’t free.

There is truth to both of these. Contexts and languages are different! If you care about performance, you should probably do benchmarks and don’t listen to people on the internet.

Both typed errors and exceptions

What’s definitely not the best for performance is using both typed errors and exceptions simultaneously. We’ve covered this in the previous lesson, but it makes sense to revisit it after everything we’ve just learned.

For example, when we have IO of Result, we have two error channels — we must handle both typed errors (in Result) and exceptions (in IO).

Doing this occasionally (in specific situations) is fine, but doing this all the time means you have to deal with the shortcomings of both approaches all the time.

Neither / The gray area

Note that there are libraries and concepts that try to find a compromise (something in between).

💡 If you’re interested, a few starting points: “effects”, “abilities”, and “bifunctor IO”.

Two examples

To wrap it up, we want to share two concrete error-handling patterns (or ways to tame errors).

In both cases, our first rule of thumb is either to deal with programming mistakes using the techniques we covered in the rest of the course or make the program crash.

Approach 1. Exceptions for exceptional behavior

We use exceptions (untyped errors) only for exceptional behavior and situations; for example, stack-overflow, out-of-memory, file system failures, unreachable databases, and so on.

Because there is nothing we can do in any of these cases, we don’t need to track and distinguish between these errors/exceptions. This allows us to catch (and report or throw away) all the exceptions only once on the highest level: the http request handler, main application loop, or other entrypoints.

If there are any other failures we handle them and use in control flow. We track these using proper types and values (Result, Optional, custom ADT, whichever one fits, as we discussed in the previous lesson).

💡 And we keep in mind what information is needed for reporting vs. control flow.

The problem with this approach is the poor composition of typed errors, as we’ve seen before. If this is rare, then it’s manageable; otherwise, it’s annoying.

Approach 2. There and back again

This approach deals with the typed-error-composition problem by relying on the fact that it’s quite easy to convert between typed errors and exceptions.

Imagine we have a simple http server: it gets a request with some input, gets something out of a database, does some data aggregations, and returns a response.

On the “lowest” level (an edge of the server) is a function that interacts with a database

getCustomer :: CustomerId -> CustomerInfo (+ Exceptions)
Enter fullscreen mode Exit fullscreen mode

We know that we interact with the outside world (we know that database requests might fail) — the exceptions are unavoidable. We can leave the exceptions be, because it’s part of the business logic to decide what to do about database failures (whether it’s part of the control flow or exceptional behavior that must be reported).

There is still room for creativity on this level. Sometimes, it might be useful to rearrange/regroup the errors — still keep the exceptions outside of the types, but separate the recoverable errors from non-recoverable (to make it easier for the callers) or wrap those in a custom exception (because keeping low-level exceptions can be an encapsulation leak).

getCustomer :: CustomerId -> CustomerInfo (+ Exceptions including MyDbException)
Enter fullscreen mode Exit fullscreen mode

Another common thing to do is to make optionality explicit. For example, if we know that a user might be missing/not found, we can make it explicit and easier for the callers:

getCustomer :: CustomerId -> Option<CustomerInfo> (+ Exceptions)
Enter fullscreen mode Exit fullscreen mode

On the middle level (business logic), we want explicit and typed errors — we don’t want to miss any errors from the lower level, and we don’t want the higher level to miss any errors as well. The trick here is to use exceptions for composition and convert unhandled exceptions to typed errors to pass them along.

Let’s look at a concrete pseudocode example:

getCustomer :: CustomerId -> Option<CustomerInfo> (+ Exceptions)

getOrderInformation :: OrderId -> <OrderInfo> (+ Exceptions)
Enter fullscreen mode Exit fullscreen mode
logic :: CustomerId -> Result<Int, NotFoundUser | SomethingWentWrong> =
  (for
    customerInfo <- customerRepository.getCustomer(customerId)  // [0]
      .modifyException(error => SomethingWentWrong(error))      // [1]
      .convertOptionToException(NotFoundUser(customerId)))      // [2]

    items <- orderService
      .getOrderItems(customerInfo.lastOrderId)                  // [3]
      .handleError(error => List.empty())                       // [4]

  yield doSomething(items)).exceptionsToResult                  // [5]
Enter fullscreen mode Exit fullscreen mode
  • (0) We just saw that getCustomer returns optional customer info and can throw exceptions.
  • (1) Because there’s nothing we can do about database errors, we just wrap it in a custom SomethingWentWrong. This way, we keep the error details for debugging needs and at the same time signal to the callers of the function that there is nothing specific they can do or worry about. Note that we can also convert errors to another error hierarchy (in the absence of union types) or skip this conversion (if we did all the exception modifications on the lower level or didn’t need any modification at all).
  • (2) Because we can’t compose Options with other types of failures (in this case, exceptions), we convert Option into exception: None becomes NotFoundUser, and Some customerId stays as customerId. We would do the same for any other failure.
  • (3) Then we take lastOrderId from customerInfo and fetch the order details from the order service.
  • (4) In this example, we know that the order service can fail to return order info, and we don’t care. That’s why in case of any error we default to an empty list.
  • (5) The last thing we do is some calculations or aggregations on the items that return an integer. And convert exceptions to typed errors. Note that it makes use of union types.
  • The result is either an integer or one of the failures we explicitly specified in this function. There is no other exception the function can throw.

🤓 Technically, we can’t guarantee that the function has no exceptions, but in this case, we handle all the exceptions from each function, so if some exception still happens, we have a programming mistake.

🤔 Because we wanted to show everything in one snippet, it might seem like error handling takes up too much space.

At the “highest” level (the other edge of the program), we don’t want to miss any errors. Because we’ve done all the ground work on the lower levels, the only thing left to do is to convert all the errors into “final reports”: http responses with proper bodies and status codes, logs for developers, and so on. It should all be explicit and simple at this point:

toResponse :: Result[A, InvalidInput | NotFoundUser | SomethingWentWrong] =
  case Err(InvalidInput(message)) => badRequest(message)
  case Err(NotFoundUser(message)) => 
    Logger.debug("Not found...")
    notFound(message)
  case Err(SomethingWentWrong(internal)) => 
    Logger.error("Some useful information, {}", internal.getErrorMessage())
    internalServerError("Some nice message")
  case Ok(body) => as200(body)
Enter fullscreen mode Exit fullscreen mode

💡 Note that we can write and reuse only one error handler for all the errors or have specific handlers for specific cases.

Take-aways

So, it sounds silly, but we should try to avoid programming mistakes in the first place (using proper types, for example). We should be cautious if we use errors for control flow or reporting. And we should find a balance between typed errors and exceptions. Exceptions are easy to compose but have no contract and are easy to forget about.

Every team tries it differently. If you’re just starting at a new job, start by doing what they do and later try to improve. Or, if you’re working on a pet project, play around, and see what works and doesn’t.


Learn more about happy path programming and make FP click by joining “How to think like a functional programmer”.


Top comments (0)