What are the trade-offs between anyhow::Error::downcast and downcast_ref for runtime type checking?

downcast returns an owned T from the error by consuming the Error, while downcast_ref returns a reference &T without consuming the Error. This fundamental difference means downcast is useful when you need to extract and use the concrete error type, while downcast_ref is useful for inspecting or matching on error types without taking ownership—critical when you need to continue using the error for other purposes like chaining, logging, or further downcasts.

The anyhow Error Type

use anyhow::Error;
use std::fmt;
 
// anyhow::Error can hold any error that implements:
// - std::error::Error
// - Send + Sync + 'static
 
fn basic_error() -> Result<(), Error> {
    // anyhow::Error wraps concrete error types
    Err(Error::msg("something went wrong"))
}
 
// You can downcast to retrieve the concrete type
fn concrete_error() -> Result<(), Error> {
    Err(Error::new(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    )))
}

anyhow::Error is a type-erased error container that can hold any error type.

The downcast_ref Method

use anyhow::Error;
 
fn downcast_ref_example() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    // downcast_ref returns Option<&T>
    // It borrows the Error, doesn't consume it
    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
        println!("IO error: {}", io_error);
        println!("Kind: {:?}", io_error.kind());
    }
    
    // error is still available after downcast_ref
    println!("Full error: {}", error);
    
    // You can downcast multiple times
    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
        println!("Still accessible: {}", io_error);
    }
}

downcast_ref borrows the error and returns a reference to the inner type if it matches.

The downcast Method

use anyhow::Error;
 
fn downcast_example() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    // downcast returns Result<T, Error>
    // It CONSUMES the Error
    match error.downcast::<std::io::Error>() {
        Ok(io_error) => {
            // We own the io::Error now
            println!("IO error: {}", io_error);
            println!("Kind: {:?}", io_error.kind());
            // error is no longer accessible
        }
        Err(original_error) => {
            // Type didn't match
            // original_error is returned so you don't lose it
            println!("Not an IO error: {}", original_error);
        }
    }
    
    // After downcast, 'error' is moved/consumed
    // Cannot use it here
}

downcast consumes the error and returns either the inner type or the original error on mismatch.

Key Difference: Ownership

use anyhow::Error;
 
fn ownership_comparison() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    // downcast_ref: Borrows, doesn't consume
    if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
        println!("Got reference: {}", io_error);
    }
    // error is still valid here
    println!("Error still exists: {}", error);
    
    // downcast: Consumes
    let result = error.downcast::<std::io::Error>();
    // error is MOVED here, no longer accessible
    
    match result {
        Ok(io_error) => {
            // We OWN the std::io::Error now
            // Can return it, modify it, etc.
        }
        Err(original_error) => {
            // We still own the anyhow::Error
            // Type didn't match
        }
    }
}

The core trade-off: downcast_ref preserves ownership, downcast transfers it.

Use Case: Inspecting Without Consuming

use anyhow::Error;
 
fn inspect_error(error: &Error) {
    // When you need to inspect but keep the error:
    
    // Log based on error type
    if error.downcast_ref::<std::io::Error>().is_some() {
        println!("IO error occurred");
    } else if error.downcast_ref::<reqwest::Error>().is_some() {
        println!("HTTP error occurred");
    }
    
    // Pattern match on multiple types
    if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
        match io_err.kind() {
            std::io::ErrorKind::NotFound => println!("File not found"),
            std::io::ErrorKind::PermissionDenied => println!("Permission denied"),
            _ => println!("Other IO error"),
        }
    }
    
    // error is still usable after inspection
}
 
fn caller() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    inspect_error(&error);
    
    // Can still use error after inspection
    println!("Original error: {}", error);
}

Use downcast_ref when you need to inspect errors without consuming them.

Use Case: Extracting and Returning

use anyhow::Error;
 
// When you need to convert anyhow::Error back to a concrete type
fn convert_to_concrete(error: Error) -> Result<String, std::io::Error> {
    // downcast to get owned std::io::Error
    match error.downcast::<std::io::Error>() {
        Ok(io_error) => {
            // We own the io::Error, can return it
            Err(io_error)
        }
        Err(other_error) => {
            // Not an io::Error
            // other_error is the original anyhow::Error
            // Convert it to string
            Ok(format!("Other error: {}", other_error))
        }
    }
}
 
// This pattern is useful when:
// - Interfacing with code that expects concrete error types
// - Converting from anyhow back to specific errors

Use downcast when you need owned error types for further processing or return.

Use Case: Multiple Downcast Attempts

use anyhow::Error;
 
fn multiple_downcasts(error: &Error) {
    // downcast_ref allows multiple attempts
    
    if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
        println!("IO: {}", io_err);
    } else if let Some(http_err) = error.downcast_ref::<reqwest::Error>() {
        println!("HTTP: {}", http_err);
    } else if let Some(json_err) = error.downcast_ref::<serde_json::Error>() {
        println!("JSON: {}", json_err);
    } else {
        println!("Unknown error type");
    }
    
    // With downcast(), you'd have to handle ownership:
    // error.downcast::<std::io::Error>()
    //   .map(|e| ...)
    //   .or_else(|e| e.downcast::<reqwest::Error>())
    //   .or_else(|e| e.downcast::<serde_json::Error>())
    // Much more awkward!
}

downcast_ref is cleaner when checking multiple error types.

Use Case: Error Chaining

use anyhow::{Error, Context};
 
fn with_context() -> Result<(), Error> {
    std::fs::read_to_string("config.txt")
        .context("Failed to read config")?;
    
    Ok(())
}
 
fn handle_error() {
    match with_context() {
        Ok(_) => println!("Success"),
        Err(error) => {
            // With downcast_ref, we can inspect the chain
            // and still use the full error
            
            if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
                println!("Root cause is IO: {}", io_err);
            }
            
            // Still have full error chain for display
            for cause in error.chain() {
                println!("Cause: {}", cause);
            }
            
            // Still have full context
            println!("Full error: {:?}", error);
        }
    }
}

downcast_ref preserves the error chain and context for further use.

Performance Considerations

use anyhow::Error;
 
fn performance_comparison() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    // downcast_ref: Borrow, check type, return reference
    // - No allocation
    // - No cloning
    // - Just type check and pointer cast
    
    if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
        // Fast: just a type check and reference
    }
    
    // downcast: Move, type check, return inner or original
    // - No allocation on success
    // - On failure: returns original Error (moved)
    
    let result = error.downcast::<std::io::Error>();
    
    // Both are O(1) - just type checking
    // The difference is ownership semantics, not performance
}

Both methods have similar performance; the difference is ownership, not speed.

Error Type Not Matching

use anyhow::Error;
 
fn type_mismatch() {
    let error: Error = std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file not found"
    ).into();
    
    // downcast_ref: Returns None if type doesn't match
    if let Some(parse_err) = error.downcast_ref::<serde_json::Error>() {
        // Not reached - it's an io::Error, not json::Error
    } else {
        println!("Not a JSON error");
    }
    
    // downcast: Returns Err(original_error) if type doesn't match
    let result = error.downcast::<serde_json::Error>();
    match result {
        Ok(_parse_err) => {
            // Won't reach here
        }
        Err(original_error) => {
            // We get the original error back
            println!("Not a JSON error: {}", original_error);
            // original_error is still usable
        }
    }
}

Both methods safely handle type mismatches; downcast_ref returns None, downcast returns Err.

Working with Custom Errors

use anyhow::Error;
use std::fmt;
 
#[derive(Debug)]
enum MyError {
    NetworkError(String),
    ValidationError(String),
}
 
impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MyError::NetworkError(msg) => write!(f, "Network: {}", msg),
            MyError::ValidationError(msg) => write!(f, "Validation: {}", msg),
        }
    }
}
 
impl std::error::Error for MyError {}
 
fn custom_error_handling() {
    let error: Error = MyError::NetworkError("Connection timeout".into()).into();
    
    // downcast_ref to inspect
    if let Some(my_err) = error.downcast_ref::<MyError>() {
        match my_err {
            MyError::NetworkError(msg) => println!("Network issue: {}", msg),
            MyError::ValidationError(msg) => println!("Validation issue: {}", msg),
        }
    }
    
    // downcast to extract
    let error: Error = MyError::ValidationError("Invalid input".into()).into();
    match error.downcast::<MyError>() {
        Ok(MyError::ValidationError(msg)) => {
            println!("Validation failed: {}", msg);
            // We own the error data now
        }
        Ok(MyError::NetworkError(msg)) => {
            println!("Network failed: {}", msg);
        }
        Err(other) => {
            println!("Different error type: {}", other);
        }
    }
}

Both methods work with custom error types implementing std::error::Error.

Downcasting in Error Handlers

use anyhow::Error;
 
fn error_handler(error: &Error) {
    // Pattern: Try multiple known error types
    
    // Check for specific errors we know how to handle
    if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
        match io_err.kind() {
            std::io::ErrorKind::NotFound => {
                println!("File not found - check path");
            }
            std::io::ErrorKind::PermissionDenied => {
                println!("Permission denied - check permissions");
            }
            _ => {
                println!("IO error: {}", io_err);
            }
        }
    } else if let Some(http_err) = error.downcast_ref::<reqwest::Error>() {
        if http_err.is_timeout() {
            println!("Request timed out - retry");
        } else {
            println!("HTTP error: {}", http_err);
        }
    } else {
        // Unknown error type
        println!("Unknown error: {}", error);
    }
}

downcast_ref enables error-type-specific handling without consuming the error.

Downcasting for Error Conversion

use anyhow::Error;
 
// Convert anyhow::Error to specific error types for library boundaries
fn convert_to_io_error(error: Error) -> std::io::Error {
    match error.downcast::<std::io::Error>() {
        Ok(io_error) => io_error,
        Err(other_error) => {
            // Not an io::Error, wrap it
            std::io::Error::new(
                std::io::ErrorKind::Other,
                other_error.to_string()
            )
        }
    }
}
 
// This pattern is useful when:
// - Library returns anyhow::Error internally
// - Public API must return specific error type
fn library_function() -> Result<String, std::io::Error> {
    internal_function().map_err(|e| convert_to_io_error(e))
}
 
fn internal_function() -> Result<String, Error> {
    // May return various error types
    Ok("result".to_string())
}

downcast is essential when converting from anyhow::Error to concrete error types.

Method Signatures

use anyhow::Error;
 
// Method signatures:
impl Error {
    // downcast_ref: Returns Option<&T>
    pub fn downcast_ref<T: std::error::Error + Send + Sync + 'static>(&self) 
        -> Option<&T> 
    {
        // Borrow self, return reference to inner type
    }
    
    // downcast: Returns Result<T, Error>
    pub fn downcast<T: std::error::Error + Send + Sync + 'static>(self) 
        -> Result<T, Error> 
    {
        // Consume self, return owned inner type or original error
    }
}

The signatures encode the ownership semantics: &self vs self.

Synthesis

Comparison table:

Aspect downcast_ref downcast
Ownership Borrows Error Consumes Error
Return type Option<&T> Result<T, Error>
Error on mismatch None Err(original_error)
Can use error after? Yes No (moved)
Multiple downcasts Straightforward Awkward (must chain)
Use case Inspection, matching Extraction, conversion

When to use downcast_ref:

// Inspecting errors without consuming
if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
    // Handle based on error kind
}
 
// Multiple type checks
if error.downcast_ref::<std::io::Error>().is_some() {
    // Handle IO
} else if error.downcast_ref::<reqwest::Error>().is_some() {
    // Handle HTTP
}
 
// Error logging/reporting (preserves full error)
log_error(&error);  // Can still use error after

When to use downcast:

// Converting to concrete error type
match error.downcast::<std::io::Error>() {
    Ok(io_err) => return Err(io_err),  // Return concrete type
    Err(other) => { /* handle other */ }
}
 
// Extracting and processing owned error
let io_error = error.downcast::<std::io::Error>()?;
// Own the std::io::Error, can modify or return it
 
// Library boundary conversion
fn to_concrete(error: Error) -> Result<Data, std::io::Error> {
    // Must return std::io::Error, not anyhow::Error
    match error.downcast() {
        Ok(io_err) => Err(io_err),
        Err(other) => Err(std::io::Error::new(Other, other)),
    }
}

Key insight: The trade-off between downcast and downcast_ref is purely about ownership, not functionality or performance. downcast_ref is the right choice when you're inspecting errors for handling, logging, or conditional logic—the error remains usable after inspection. downcast is the right choice when you need to extract the concrete error type for return, further processing, or when converting from anyhow::Error to a specific error type for library boundaries. The downcast method's Result<T, Error> return type is specifically designed to preserve the original error on mismatch, so you don't lose information when the type doesn't match. Use downcast_ref by default; use downcast when you specifically need ownership of the inner error.