DEV Community

Cover image for Rust Error Handling Patterns I Actually Use in Production
hiyoyo
hiyoyo

Posted on

Rust Error Handling Patterns I Actually Use in Production

If this is useful, a ❤️ helps others find it.

All tests run on an 8-year-old MacBook Air.

Every Rust tutorial covers Result and ?. Few cover what to actually do when you have 5 different error types flying around a real application.

Here's what I settled on after shipping multiple Tauri apps.


The problem: heterogeneous errors

A PDF processing command might fail due to:

  • IO error (file not found)
  • lopdf parse error (malformed PDF)
  • Encryption error (wrong key)
  • Swift sidecar error (process failed)
  • Serialization error (JSON parse)

Returning Box works but loses type information. Returning String works but is unstructured. Neither is great.


Pattern 1: domain error enum

Define one error type per domain:

#[derive(Debug, thiserror::Error)]
pub enum PdfError {
    #[error("file not found: {path}")]
    NotFound { path: String },

    #[error("malformed PDF: {reason}")]
    ParseError { reason: String },

    #[error("decryption failed — wrong password or corrupted file")]
    DecryptionFailed,

    #[error("sidecar process failed: {stderr}")]
    SidecarError { stderr: String },

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}
Enter fullscreen mode Exit fullscreen mode

thiserror generates the Display and Error impls. #[from] generates From automatically — ? just works.


Pattern 2: convert at the boundary

Tauri commands need Result for the frontend. Convert at the command boundary, not inside business logic:

// Business logic — rich error types
pub fn process_pdf(path: &str) -> Result {
    // ...
}

// Tauri command — converts at boundary
#[tauri::command]
pub fn process(path: String) -> Result {
    process_pdf(&path).map_err(|e| e.to_string())
}
Enter fullscreen mode Exit fullscreen mode

The frontend gets a human-readable error string. The backend keeps structured errors for matching and logging.


Pattern 3: context with anyhow

For scripts and one-off tools where rich error types aren't worth the boilerplate, anyhow adds context without ceremony:

use anyhow::{Context, Result};

pub fn load_config(path: &str) -> Result {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;

    serde_json::from_str(&content)
        .with_context(|| "config file is not valid JSON")
}
Enter fullscreen mode Exit fullscreen mode

Error messages chain automatically: "config file is not valid JSON: unexpected character at line 3"


Pattern 4: never panic in library code

unwrap() and expect() in library functions crash the whole app. In a long-running desktop app, a crash from a malformed PDF file is unacceptable.

Rule: unwrap() only in main() for setup that must succeed, or in tests.

// Bad — panics if PDF is malformed
let doc = Document::load(path).unwrap();

// Good — surfaces error to caller
let doc = Document::load(path)
    .map_err(|e| PdfError::ParseError { reason: e.to_string() })?;
Enter fullscreen mode Exit fullscreen mode

What I actually use

  • thiserror for domain error enums in library code
  • anyhow for scripts and one-off tools
  • .map_err(|e| e.to_string()) at Tauri command boundaries
  • Never unwrap() in non-test, non-main code

Hiyoko PDF Vault → https://hiyokoko.gumroad.com/l/HiyokoPDFVault
X → @hiyoyok

Top comments (0)