Result in Rust: Custom Errors & vs Panics Explained

Max WellsMax WellsFounder of Rustify

TL;DR: Result<T, E> is Rust's built-in enum for operations that can succeed or fail. Ok(T) holds the success value; Err(E) holds the error. Unlike exceptions in Java/Python, errors in Rust are values; they appear in function signatures, cannot be silently ignored, and are handled with match, if let, or the ? operator. This makes error handling explicit, composable, and zero-cost.


What Is Result<T, E> in Rust?

Result<T, E> is an enum that represents either a successful value Ok(T) or an error Err(E); it's Rust's primary mechanism for recoverable error handling.

enum Result<T, E> {
    Ok(T),  // operation succeeded with value T
    Err(E), // operation failed with error E
}

Rust has no exceptions. Instead, functions that can fail return Result. The compiler ensures you cannot use the success value without checking whether the operation succeeded; eliminating swallowed exceptions and unhandled error states.


How Do You Use Result?

The primary tools are match for exhaustive handling, if let for single-case handling, and ? for propagating errors up the call stack.

use std::num::ParseIntError;
 
fn parse_age(s: &str) -> Result<u8, ParseIntError> {
    let n: u8 = s.parse()?; // ? propagates Err automatically
    Ok(n)
}
 
fn main() {
    // Full match; handles both cases
    match parse_age("25") {
        Ok(age) => println!("Age: {age}"),
        Err(e) => println!("Parse error: {e}"),
    }
 
    // unwrap_or; provide a default on failure
    let age = parse_age("abc").unwrap_or(0);
 
    // map; transform Ok value
    let doubled = parse_age("25").map(|n| n * 2);
 
    // is_ok / is_err; boolean check
    if parse_age("30").is_ok() {
        println!("Valid age");
    }
}

How Does the ? Operator Work?

The ? operator on a Result returns Err early from the current function if the value is an error, or unwraps Ok and continues; making error propagation concise.

use std::fs;
use std::io;
 
fn read_config(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?; // returns Err early if file missing
    let trimmed = content.trim().to_string();
    Ok(trimmed)
}
 
// Equivalent without ?:
fn read_config_verbose(path: &str) -> Result<String, io::Error> {
    match fs::read_to_string(path) {
        Err(e) => return Err(e),
        Ok(content) => Ok(content.trim().to_string()),
    }
}

? also calls .into() on the error, enabling automatic conversion between error types when the target error type implements From<SourceError>.


How Do You Define Custom Error Types?

For application code, the common approach is an enum of error variants. For library code, implement std::error::Error.

use std::fmt;
 
#[derive(Debug)]
enum AppError {
    NotFound(String),
    InvalidInput(String),
    Database(String),
}
 
impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "Not found: {msg}"),
            AppError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
            AppError::Database(msg) => write!(f, "Database error: {msg}"),
        }
    }
}
 
fn find_user(id: u64) -> Result<String, AppError> {
    if id == 0 {
        return Err(AppError::InvalidInput("id cannot be zero".to_string()));
    }
    Ok("Alice".to_string())
}

In production Rust, crates like thiserror (derive macros for error types) and anyhow (ergonomic error boxing for applications) simplify this significantly.


What Is the Difference Between Result and Panics?

Result is for recoverable errors (bad input, missing file, network failure). Panics are for unrecoverable programming errors (index out of bounds, assertion failure).

SituationUse
File not foundResult<_, io::Error>
Invalid user inputResult<_, ValidationError>
Database query failedResult<_, sqlx::Error>
Index out of boundsPanic (bug in your code)
Assertion failed in testPanic
Impossible state reachedunreachable!() macro (panics)

Production Rust servers catch panics at the request boundary (Axum/Actix do this automatically) to prevent one bad request from crashing the whole server.


Frequently Asked Questions

Rarely. .unwrap() panics on Err; acceptable in tests or when you can prove None/Err is impossible (e.g., "42".parse::<i32>().unwrap()). In production handlers, propagate with ? or handle with match/.unwrap_or().

thiserror provides derive macros to implement std::error::Error on your custom error enums cleanly; ideal for library crates that want to expose typed errors. anyhow provides anyhow::Error, a boxed error type that erases the concrete type; ideal for application code where you just want to propagate errors up without defining custom types.

Yes. Iterator::collect::<Result<Vec<T>, E>>() collects an iterator of Result<T, E> into a Result<Vec<T>, E>; returning the first error encountered, or Ok(vec) if all succeeded.

let results: Result<Vec<i32>, _> = vec!["1", "2", "three"]
    .iter()
    .map(|s| s.parse::<i32>())
    .collect();
// Err(ParseIntError) because "three" fails

.ok_or(err) on Option<T> gives Result<T, E>. .ok() on Result<T, E> gives Option<T> (discarding the error). Use these at boundaries between code that uses different types.


Sources


  • Option: Rust's null-safety type, similar pattern
  • Enum: Result is an enum
  • Trait: Result implements Iterator, From, and more
  • From / Into: Result-based APIs rely on From conversions for ergonomic error propagation
  • serde_json: Parsing and serializing JSON in Rust usually returns Result
  • Panic: Result exists so recoverable failures do not need to become panics
  • Regex: Compiling a regex returns Result, which makes it a common example in Rust error handling

Keep Reading

Ready to Land a $120k+ Rust Job in the US or Europe?