The railway to better error handling
How monadic error handling keeps your code on track while exceptions derail your day.
I was writing a new post about replacing exceptions in Tsonnet with a monadic approach, and the discussion also came up with friends recently, so I believe monadic error handling deserves its own explanation.
Let me digress about monadic error handling and why exceptions are not a good idea in general in comparison.
What is monadic error handling?
Monadic error handling is an approach where errors are treated as regular values wrapped in a type (like Result in OCaml) representing success or failure. This type provides a way to chain operations (bind
or >>=
) that might fail, allowing errors to propagate through computations in a controlled and composable way.
Here's a quick example in OCaml:
I will continue using OCaml for the code examples in this post, but don't get discouraged, the ideas present here are relevant and applicable to any programming language that allows us to treat errors as values.
This error-handling style is also known as Railway Oriented Programming, where computations are seen as trains running on two tracks — one for success and one for failure.
Let's look closely at how exceptions compare.
Why not exceptions?
Let's examine why exceptions might not be the best choice for error handling.
One key issue is that exceptions break referential transparency — a core principle where a function call can be replaced with its result without changing program behavior. Let's see this through an example:
When using exceptions, the function's type signature doesn't tell us it might fail:
But with Result, the possibility of failure becomes part of the type:
This explicit error handling through types brings several advantages. First, it makes error cases visible in the type system, forcing developers to consider and handle them during compilation. When you see a Result type, you know immediately that you need to handle both success and failure cases.
Key advantages of the monadic approach:
Explicit error flow
Better composability
Error context preservation
No control flow interruption
Pattern matching integration
Let's explore a real-life example and see how it would be applied less trivially.
The monadic approach applied to a distributed system
Let's explore a real-life example from distributed systems. User registration is a common operation that involves multiple steps where things can go wrong — perfect for demonstrating error-handling approaches.
Here's the code implementing both approaches, with comments to highlight the important parts:
A few key points:
The Result signatures make it explicit which functions can fail and force error handling at compile time. With exceptions, failures are invisible in the type system.
Notice how exception handling requires nested try-catch blocks as errors multiply, while the Result version composes linearly with
bind
?In the Result version, the error path is as clear as the success path. Exception handling obscures the normal flow of data with multiple exit points.
Different error types can be composed naturally in the Result version. With exceptions, you often need to catch and convert between exception types, leading to error information loss.
Adding a new operation that might fail in the Result version just means adding another `bind`. In the exception version, you need to carefully restructure the try-catch blocks to maintain correct error handling.
The monadic Result approach fits particularly well with OCaml's type system and functional programming style. It encourages thinking about error cases as data transformations rather than control flow interruptions, leading to more maintainable and predictable code.
When to use exceptions?
There are specific scenarios where exceptions might be more appropriate than monadic error handling:
Truly exceptional cases: a stack overflow or out-of-memory are truly exceptional cases.
Performance-critical cases: when too many Result allocations could penalize.
When the program cannot continue: if a failure means you can't continue, a fatal exception is acceptable.
Prototyping or quick scripts: who cares, right? The maintainability is usually not a concern in scenarios where code is thrown away later.
However, these are exceptions (pun intended) to the rule. For most business logic and data processing, the monadic approach remains the better choice for its composability and type safety.
Conclusion
Monadic error handling offers a more principled approach to dealing with errors in your code. While exceptions have their place in specific scenarios, treating errors as values through the Result type leads to more maintainable, composable, and type-safe code. The explicit nature of this approach helps developers reason about error cases and handle them appropriately, making it an excellent choice for most business logic and data processing needs.
From my own personal experience dealing with languages that handle this on three different ways:
In C#, you have exceptions and need to try..except them or be bugged by unhandled exceptions. Knowing what can fail requires some digging and it's often necessary to go through additional documentation when the API is not clear or is a black box;
In Go, by convention, errors are types and there's no try..catch/except construct. It feels weird, but over time it makes you consider errors in your design. "An error happened" becomes more important than "what error happened" over 90% of scenarios, and the difference from an error to an expection is also made obvious by making exceptions panics. The disadvantage here is that you need to adopt a "hive mindset" to do this thing property and maybe that's why Go peeps are seen as members of a cult;
In Scala, you follow the monadic approach towards exceptions. It's very elegant, reads clearly, follows a great flow. But it becomes difficult for people that need to read the code later, if they need to touch a certain code path that can raise an intermediate exception.
---
As such, from the standpoint of "It's more important to deliver features using simple code that people will be able to read later, and that allows us to move quicker", Go is still my personal choice, although it looks kinda "primitive" (and that's the whole point of the language). For the following reasons:
- readable code for posterity is more important than clever code;
- clever code makes you feel good once you write it, but becomes a burden once you have to return to it;
- for the same reason that we read from left to right, it's important to understand execution flow from beginning to end. The various `if err = nil` feel weird, but we are reading a description of what our program is supposed to do, and errors are part of that description.
---
Regardless, as always, great article!