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:

  1. Loss of context - A string doesn't tell us what kind of error occurred
  2. Difficult error handling - Callers can't distinguish between different errors
  3. 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 From to 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.