Improving Error Handling with Custom Error Types
As our program grows, generic String errors become problematic. Oxide (following Rust) allows us to create custom error types for better error handling.
The Problem with String Errors
Currently, we're returning Result<Config, String>. This has issues:
- Loss of context - A string doesn't tell us what kind of error occurred
- Difficult error handling - Callers can't distinguish between different errors
- Hard to extend - Adding new error types requires refactoring
// Current approach - hard to work with
let config = Config.new(&args)
.unwrapOrElse { err ->
eprintln!("Error: {}", err)
std.process.exit(1)
}?
Creating a Custom Error Type
Let's define an AppError enum that represents the different errors our program can encounter:
import std.fmt
import std.error.Error
import std.io
enum AppError {
ArgumentError(String),
FileError(io.Error),
SearchError(String),
}
Now we need to implement Display and Error traits for proper error handling:
extension AppError: fmt.Display {
fn fmt(f: &mut fmt.Formatter): fmt.Result {
match self {
AppError.ArgumentError(msg) -> {
write!(f, "Argument error: {}", msg)
},
AppError.FileError(err) -> {
write!(f, "File error: {}", err)
},
AppError.SearchError(msg) -> {
write!(f, "Search error: {}", msg)
},
}
}
}
extension AppError: fmt.Debug {
fn fmt(f: &mut fmt.Formatter): fmt.Result {
write!(f, "{:?}", self)
}
}
extension AppError: From<io.Error> {
static fn from(err: io.Error): Self {
AppError.FileError(err)
}
}
Using the Custom Error Type
Now update Config to return our custom error:
extension Config {
static fn new(args: &Vec<String>): Result<Config, AppError> {
if args.len() < 3 {
return Err(AppError.ArgumentError(
"not enough arguments\nusage: oxgrep <query> <filename>".toString()
))
}
let query = args[1].clone()
let filename = args[2].clone()
let ignoreCase = args.len() > 3 && args[3] == "--ignore-case"
Ok(Config {
query,
filename,
ignoreCase,
})
}
}
Update the main function:
fn main(): Result<(), Box<dyn Error>> {
let args = std.env.args().collect<Vec<String>>()
let config = Config.new(&args)
.unwrapOrElse { err ->
eprintln!("Problem parsing arguments: {}", err)
std.process.exit(1)
}?
run(config)
}
fn run(config: Config): Result<(), Box<dyn Error>> {
let contents = std.fs.readToString(&config.filename)?
for line in contents.lines() {
if line.contains(&config.query) {
println!("{}", line)
}
}
Ok(())
}
The From<io.Error> implementation allows automatic conversion. When readToString returns an IO error, it's automatically converted to our AppError.
Detailed Error Messages
With custom error types, we can provide context-specific messages:
enum AppError {
ArgumentError {
message: String,
usage: String,
},
FileNotFound(String),
PermissionDenied(String),
InvalidEncoding,
}
extension AppError: fmt.Display {
fn fmt(f: &mut fmt.Formatter): fmt.Result {
match self {
AppError.ArgumentError { message, usage } -> {
write!(
f,
"Error: {}\n\n{}",
message, usage
)
},
AppError.FileNotFound(path) -> {
write!(f, "File not found: {}", path)
},
AppError.PermissionDenied(path) -> {
write!(f, "Permission denied reading: {}", path)
},
AppError.InvalidEncoding -> {
write!(f, "File is not valid UTF-8")
},
}
}
}
The ?? Operator
Oxide provides a convenient ?? operator for working with Option types. This null-coalescing operator provides a default value when an Option is None:
fn findValue(data: Vec<String>, key: String): String? {
for item in data.iter() {
if item.contains(&key) {
return Some(item.clone())
}
}
null
}
fn main(): Result<(), Box<dyn Error>> {
let data = vec!["key:value".toString(), "other:data".toString()]
// Using ?? to provide a default value
let value = findValue(data, "key".toString()) ?? "default".toString()
println!("Value: {}", value)
Ok(())
}
The ?? operator:
- Returns the inner value if
Some - Uses the right-hand default value if
None - Makes code more concise than
unwrapOr
Error Handling in Functions
When functions can fail, propagate errors with ?:
fn search(
query: &str,
contents: &str
): Result<Vec<String>, AppError> {
var results = Vec.new()
for line in contents.lines() {
if line.contains(query) {
results.push(line.toString())
}
}
if results.isEmpty() {
// Optionally return an error if nothing found
// Err(AppError.SearchError("No matches found".toString()))
}
Ok(results)
}
Summary
Custom error types improve error handling:
- Better semantics - Error types reflect actual problems
- Composability - Use
Fromto convert between error types - Easier debugging - Detailed context helps find issues
- Type safety - Match on specific error variants
- Operator support - Use
?and??for elegant error propagation
Next, we'll add support for case-insensitive searching using environment variables.