I was writing a new post about replacing exceptions in Tsonnet with a monadic approach, and the discussion also came up with friends recently too, so I believe the 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:
type ('a, 'e) result = Ok of 'a | Error of 'e
(* Chain operations that might fail *)
let process_data input =
validate input (* Returns Result *)
|> Result.bind process (* Only runs if validate succeeds *)
|> Result.bind save (* Only runs if process succeeds *)
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:
(* Using exceptions *)
let divide x y =
if y = 0 then raise Division_by_zero
else x / y
(* Using Result *)
let divide x y =
if y = 0 then Error "Division by zero"
else Ok (x / y)
When using exceptions, the function's type signature doesn't tell us it might fail:
val divide : int -> int -> int
But with Result, the possibility of failure becomes part of the type:
val divide : int -> int -> (int, string) result
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:
(* Exception-based approach *)
val validate_email : string -> string (* raises Invalid_argument *)
val validate_age : int -> int (* raises Invalid_argument *)
val create_user : int -> string -> int -> user (* raises Invalid_argument *)
(* Notice how error handling becomes nested and complex with multiple operations *)
let process_user data =
try
let user = create_user data.id data.email data.age in
try
save_to_db user; (* Could raise Database_error *)
try
notify_user user (* Could raise Network_error *)
with Network_error msg ->
log_error ("Notification failed: " ^ msg);
raise (Process_error "Notification step failed")
with Database_error msg ->
log_error ("Database error: " ^ msg);
raise (Process_error "Database step failed")
with
| Invalid_argument msg ->
log_error ("Validation error: " ^ msg);
raise (Process_error "Validation failed")
| Process_error msg as e ->
log_error msg;
raise e
(* Monadic Result approach *)
val validate_email : string -> (string, error) result
val validate_age : int -> (int, error) result
val create_user : int -> string -> int -> (user, error) result
(* Error handling flows naturally with the data transformation *)
let process_user data =
let open Result in
match
create_user data.id data.email data.age
|> bind save_to_db (* Different error types compose naturally *)
|> bind notify_user (* Chain operations without nesting *)
with
| Ok _ -> log_success "User processed"
| Error err ->
match err with
| Validation_error msg -> log_error ("Validation: " ^ msg)
| Database_error msg -> log_error ("Database: " ^ msg)
| Network_error msg -> log_error ("Network: " ^ msg)
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.
Thanks for reading Bit Maybe Wise! Subscribe to get future posts wrapped in a shiny Ok constructor, delivered straight to your inbox.
Top comments (0)