Why value based errors?
Simpler error handling for the 21st century.
The means and methods of error handling have evolved significantly over time, from beeping alarms, interrupts and numerics codes, try-catch exception handling, and even encoding errors in program control flow, through the use of “effects”.
From the advent of languages like C++, Java, C#, Python and their counterparts, which introduced structured error handling generally implemnted through a mechanism known as “exceptions”. They have been the dominant means for handling errors, in most code written since.
But recently that has started to change, as now we have languages that prefer errors as values. In spirit they are similar to numeric code errors but with more descriptive messages and error handling, and they have borrowed concepts from monadic chains of Haskell to introduce error composability in several modern languages.
We can find value based errors now in several languages but the ones most popular for it is still
“Rust” Result
s. They provide almost similar semantics to Either
from Haskell, and can also be
found in languages like Kotlin, Swift and even C++(std::expected).
This shift towards representing errors through types and values, rather than relying on convoluted pseudo-DSL solutions like “exceptions”, has become has been fueled by the imperative for advancing error handling practices in software development for scaling applications over better hardware, and as the performance gains from hardware improvements have largely stalled.
what are value-based errors?
Value based errors simplify error handling by treating errors as ordinary data within a programming language, rather than relying on special control flow such as exceptions. With error values, errors themselves are implemented using first-class features — they become instances of a dedicated error types or enums, containing all pertinent information about the encountered failure.
For instance, in Rust, the ubiquitous Result<T, E>
enum distinguishes between
successful outcomes (Ok
variant) carrying a value of type T
, and error
outcomes (Err
variant) encapsulating an error value of type E
.
Consequently, errors become structured data, furnishing comprehensive insights
into the failure.
Similarly, Go simplifies error handling through its universal error value type. Here, an error is merely a type that conforms to the error interface. This design leverages language features to enforce error handling and communicate error specifics to callers. While this approach streamlines code and enhances user experience by integrating error handling directly into the language, it introduces complexities and limits composability.
In a similar vein, Zig adopts custom semantics for error handling, utilizing the error type to represent global errors. However, it enhances composability and simplifies error management by introducing additional language constructs for handling errors and separate optional types to mitigate the “NULL” value issue.
To grasp the essence of error values, let’s explore a straightforward example.
fn main() { let x: u32 = 10u32.div(0); println!("{x}"); }
Above code will produce runtime error thread 'main' panicked at ...: attempt to divide by zero
because div
attempts division with rhs == 0
.
fn main() { let x: Option<u32> = 10u32.checked_div(0); println!("{x:?}"); // output: None }
Above code works fine but prints None
which is the None
value of the Option
type. Which represents the absence of a value. As such Option
al types here avoid the need for the NULL
value problem or having to provide exceptions as common in most OOP languages.
why not just use exceptions?
There are several key issues with exceptions,
-
Exception handling can introduce non-linear control flow, making it more challenging to reason about program behavior, especially in complex codebases. This can lead to difficulties in understanding, debugging, and maintaining the code.
-
When using exceptions, it’s crucial to ensure that error conditions are transparently communicated to callers. Failure to do so can result in unexpected behavior, especially when exceptions are thrown from deep within library code that callers might not be aware of.
-
In languages like C++, where RAII is prevalent for resource management, exceptions can introduce subtle bugs if not handled correctly. For instance, if an exception occurs during stack unwinding while cleaning up resources, it can lead to resource leaks or undefined behavior.
-
Furthermore, exception mechanisms often rely on runtime type information and dynamic memory allocation, which can be impractical or inefficient in resource-constrained environments like embedded systems. Additionally, the lack of standardized exception support across all target platforms, language abis and other interfaces can make it challenging to use exceptions consistently across programming language boundaries.
how do modern languages solve error handling?
Most modern languages like Rust, Go, and Zig have distinct approaches to error handling, reflecting their design philosophies and priorities.
Let’s explore how each language tackles error handling:
go, error
interface
Go prioritizes simplicity, concurrency, and ease of use. Its error handling approach revolves around the use of multiple return values and error values, with a focus on explicitness and minimalism.
Multiple Return Values
1, Go functions commonly return both a result and an error value. This pattern allows functions to communicate success or failure without the need for exceptions or complex error handling constructs.
- Go’s error interface, defined as
error
2, is a simple interface with a single method,Error()
, which returns a string describing the error. This uniformity makes error handling straightforward and consistent across different libraries and packages.
func process(arr []int) (int, error) { for _, val := range arr { if val == 0 { return 0, fmt.Errorf("failed to process") } } return 4321, nil } func main() { x, err := process([1, 2, 3, 4]) if err != nil { fmt.Println("error: ", err) os.Exit(-1) } fmt.Println("x: %d", x) }
-
Go provides the
defer
3 keyword for executing cleanup code before a function returns. While not specifically for error handling,defer
can be useful for resource cleanup in the presence of errors. -
As for dealing with abrupt errors that cause a
panic()
we have therecover()
4 function allows deferred functions to capture and handle panics, providing some level of error recovery.
// handling errors by containing them(if the library uses panics) // and try to recover from them if possible func process(arr []int) int { // runs at the end of the function // it will run even if the function panics defer func() { if err := recover(); err != nil { fmt.Println("error: ", err) os.Exit(-1) } }() for _, val := range arr { if val == 0 { panic("failed to process") } } return 4321 }
Although these look deceptively similar to exceptions, these represent lower level primitives of operating systems where stack unwinding needs to occur if the program is arbitrarily halted in the middle of execution, and are implemented as a thin wrapper over the primitives from the operating system.
zig, error and optional type
Zig although a relatively new systems programming language, is focused on debuggability, performance, and simplicity. And its error handling approach is designed to be explicit, predictable, easy to compose with other primitives the language offers to us.
- Zig provides error sets5, which are enums of valid errors for which each error name gets a unique unsigned integer greater than 0, and for the same error names they get assigned the same integer value. Further you can coerce an error from a subset to a superset, reducing boilerplate and simplifying error handling.
- Zig uses error union types6 to represent types that can fail, that is, to make a type
failiable all we have to write is,
!u16
where!
makes the type an error union type. They support all possible error values from all error sets making the error handling very secure. Note Zig returns traces for errors in debug mode.7
const std = @import("std"); const ProcessError = error { Failed, Timedout }; const NonProcessError = error { Failed, // same unsigned variable as ProcessError.Failed }; fn process(values: []u32) !u32 { for (values) |value| { if value == 0 { return ProcessError.Failed; } } return 4321; } fn main() !void { var arr = [_]u32{1, 2, 3, 4}; const x = process(&arr) catch |err| { std.debug.panic("error: {}\n", .{err}); }; std.debug.print("x: {d}\n", .{x}); }
- Zig supports comptime errors8, which are simply errors that are raised within a comptime context, hence are detected and reported during compilation. This helps catch errors early in the development process, improving code quality and reliability.
const std = @import("std"); const ProcessError = error { Failed, Timedout }; const NonProcessError = error { Failed, // same unsigned variable as ProcessError.Failed }; fn process(values: []u32) !u32 { for (values) |value| { if (value == 0) { return ProcessError.Failed; } } return 4321; } pub fn main() !void { comptime var arr = [_]u32{0, 1, 2, 3, 4}; const x = comptime process(&arr) catch { @panic("failed"); // will fail to compile as panic is found within comptime }; std.debug.print("x: {d}\n", .{x}); }
rust, Result<T, E>
or Option<T>
- Rust’s
Result<T, E>
9 type is used to represent operations that may succeed and return a value of typeT
, or fail with an error of typeE
. This forces the developer to handle errors explicitly, promoting safe and robust code.
- Similar to Result, Rust’s
Option<T>
10 type represents an optional value that may or may not be present. It is often used to handle cases where a value may be absent, such as when parsing input or performing lookups. And it’s primary goal is to handle cases were “NULL” is useful, and providing proper errors is cumbersome or infeasible.
- Rust’s pattern matching11 capabilities, particularly the
match
keyword, are extensively used for handling different Result and Option variants, making error handling code concise and expressive.
enum ProcessError { Failed, Timedout } fn process(values: &[u32]) -> Result<u32, ProcessError> { for value in values { if value == 0 { return Err(ProcessError::Failed); } } return Ok(4321); } fn main() { let x = match process(&[1, 2, 3, 4]) { Ok(val) => println!("x: {x}"), Err(err) => panic!("error: {:?}", err), }; }
- Rust provides the
panic!
12 macro for signaling unrecoverable errors, such as index out of bounds or failed assertions. Panics unwind the stack, running cleanup code along the way, before terminating the program.
// unrecoverable, exit fn process(values: &[u32]) -> u32 { for value in values { if value == 0 { panic!("Failed to process"); } } return 4321; }
what do we gain with modern error handling patterns?
By making errors explicit and forcing developers to handle them explicitly, modern error handling patterns promote safer and more reliable code. This reduces the likelihood of runtime errors, undefined behavior, and unexpected failures, leading to more stable and predictable applications.
Explicit error values, make code more readable and understandable. Error conditions are clearly communicated in function signatures and return types, improving code comprehension and maintainability.
These patterns further facilitate testing and maintenance efforts by providing clear boundaries between normal and error paths. Unit tests can be written to validate error handling behavior, ensuring that applications respond appropriately to different error conditions.
Additionally, errors as plain code are easier to maintain and refactor, leading to more manageable codebases over time. For example, if you change the error value you return you have to change all places where you handle the error, this being a compile time error helps us especially in languages like Rust and Zig.
but, are there any downsides?
That honestly would depend on the language we pick to discuss, but in general these patterns add a lot more verbosity within the code around handling errors. And often make writing code more cumbersome whenever errors are involved, hence leads to people using escape hatches to avoid error handling as long as possible.
This can be ignoring errors in Go, or just unwrapping all errors in Rust, and just aborting on every error case in Zig. This leads to bad initial code, which programmers being as lazy as they are being shipped to production breaking the usefulness of errors and taking us back to square one. Most languages offer solutions in form of linting to solve the issues regarding it.
Especially in Go, the focus is on just simplifying the flow of handling errors. This often leads to type issues or issues with lack of patterns around backtracing errors in large programs. Making it harder to trace errors to source and understanding the path of it.
Footnotes
-
Option type and notes about option enum and it’s advantages over null values ↩
-
panic!
macro and about non-recoverable errors ↩