Rust track

Lesson 18 · Part 4: Error Handling, Panics & Custom Errors

Custom Errors

Defining custom error types and implementing the Error trait

Part 4: Error Handling, Panics & Custom Errors5 min readFree lesson

Box<dyn Error> is fast and convenient, but there are limits when the library grows. If the caller needs to distinguish between types of errors and handle them differently, domain-specific error types are necessary. In this section, we will learn how to define a clean enum error with the thiserror crate, and automatically implement the From trait with #[from] to naturally integrate with ?.

Learning Objectives

  • Understand the meaning of the standard library std::error::Error trait.
  • Define enum error types using the thiserror::Error macro.
  • Automatically generate Display messages with the #[error("...")] attribute.
  • Smoothly convert ? by automatically implementing From with #[from].

Key Concepts

1) std::error::Error Trait

Almost all errors in the standard library implement this trait. You can implement it yourself, but it’s burdensome, so we use the help of macros.

2) What thiserror Does

  • Automatically implement Error trait with #[derive(Error)].
  • Automatically implement Display with #[error("Message {0}")].
  • Automatically implement From<Original_Error> with #[from] → automatic conversion to ?.

Cargo.toml:

[dependencies]
thiserror = "1"

3) Basic Definition Pattern

use thiserror::Error;

#[derive(Debug, Error)]
pub enum MyError {
    #[error("Integer parsing failed: {0}")]
    Parse(#[from] std::num::ParseIntError),
    #[error("Value out of range: {0}")]
    OutOfRange(i32),
}
  • Use #[from] for variants that wrap external errors.
  • For a domain-specific variant, include the data directly and interpolate it into the message as {0}, etc.

4) Library vs Application

Case Recommendation
Library (reusable) Custom error enum per domain (thiserror)
Application (executable binary) Single dynamic error like anyhow::Result — this tutorial does not cover this part

Library users should be able to see what kinds of failures are possible by type and branch accordingly.

5) Combining with ?

fn read_and_double(path: &str) -> Result<i32, AppError> {
    let s = std::fs::read_to_string(path)?; // io::Error → AppError
    let n: i32 = s.trim().parse()?;         // ParseIntError → AppError
    Ok(n * 2)
}

Errors from two sources are merged into one type in three lines.

Examples

Example 1 — ex01_define.rs: Defining a custom error

//Example 18-1: Defining a custom error enum with thiserror
//
//With just `#[derive(Error)]` and `#[error("...")]`, the Display + Error traits are automatically implemented.

use thiserror::Error;

#[derive(Debug, Error)]
enum AgeError {
    #[error("Age must be 0 or greater (currently {0})")]
    Negative(i32),
    #[error("Age is too large (maximum 150, currently {0})")]
    TooLarge(i32),
}

fn validate_age(n: i32) -> Result<u8, AgeError> {
    if n < 0 {
        return Err(AgeError::Negative(n));
    }
    if n > 150 {
        return Err(AgeError::TooLarge(n));
    }
    Ok(n as u8)
}

fn main() {
    for n in [25, -3, 200] {
        match validate_age(n) {
            Ok(v) => println!("{n} → Valid: {v}"),
            //{} is Display, {:?} is Debug — thiserror neatly provides both
            Err(e) => println!("{n} → Error: {e}"),
        }
    }
}

Example 2 — ex02_from.rs: Automatic conversion to #[from]

//Example 18-2: Automatic From implementation with #[from] + automatic conversion with ?
//
//When wrapping an external error type (e.g., ParseIntError) as one variant of our enum
//With just one line of `#[from]`, the From trait is automatically created.

use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum MyError {
    #[error("Integer parsing failed: {0}")]
    Parse(#[from] ParseIntError),     //?  ParseIntError → automatically converts to MyError
    #[error("Value out of range: {0}")]
    OutOfRange(i32),
}

fn parse_positive(s: &str) -> Result<u32, MyError> {
    let n: i32 = s.parse()?;          //ParseIntError is automatically converted to MyError
    if n < 0 {
        return Err(MyError::OutOfRange(n));
    }
    Ok(n as u32)
}

fn main() {
    for s in ["42", "abc", "-3"] {
        match parse_positive(s) {
            Ok(v) => println!("{s} → Ok({v})"),
            Err(e) => println!("{s} → Err: {e}"),
        }
    }
}

Example 3 — ex03_app.rs: Merging errors from two sources

//Example 18-3: Combining Two Types of External Errors into One Custom Error
//
//In practice, errors from different sources, like IO and parsing, occur in the same function.
//If you group them into the same custom error, it is easier for the caller to handle.

use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("IO failure: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parsing failure: {0}")]
    Parse(#[from] ParseIntError),
}

fn read_and_double(path: &str) -> Result<i32, AppError> {
    let s = std::fs::read_to_string(path)?;   // io::Error → AppError
    let n: i32 = s.trim().parse()?;           // ParseIntError → AppError
    Ok(n * 2)
}

fn main() {
    let path = std::env::temp_dir().join("lecture18_ex03.txt");
    std::fs::write(&path, "10").expect("Creating a temporary file");
    let path_str = path.to_str().expect("UTF-8 path");

    match read_and_double(path_str) {
        Ok(v) => println!("Result = {v}"),
        Err(e) => println!("Failure: {e}"),
    }

    //Intentionally using incorrect content to also show a parsing failure.
    std::fs::write(&path, "not-a-number").expect("Rewriting the temporary file");
    match read_and_double(path_str) {
        Ok(v) => println!("Result = {v}"),
        Err(e) => println!("Failure: {e}"),
    }
}

Common Mistakes

Q. What happens if you attach #[from] to the same type on two variants?

A. Implementing From will conflict and cause a compile error. If you want to map the same external error type to two variants, attach #[from] to only one variant and do not implement From directly for the other.

Q. Can I use field names in the Display message?

A. Yes. If it is a struct-like variant, you can interpolate it directly into {field}.

#[error("Age {age} is outside the range {min}~{max}")]
OutOfRange { age: i32, min: i32, max: i32 },

Q. Which is better, Box<dyn Error> or a custom error enum?

A. For quick prototyping, using Box<dyn Error> in a binary main is convenient. For libraries and reusable code, a custom enum is recommended so that the caller can distinguish the types.

Q. How does the cause chain of error messages work?

A. You can point to the cause error with the #[source] property. #[from], which automatically sets the source when converting ?, is usually more convenient.

Summary

  • Creating domain-specific custom error enums allows callers to handle them by type.
  • With thiserror::Error + #[error("...")] + #[from], boilerplate code is almost eliminated.
  • Since ? performs automatic conversion, errors from multiple sources can be neatly combined in a single line.
  • Use a custom enum for libraries, and Box<dyn Error> is fine for short application mains.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/custom_error/Cargo.tomlTOML
[package]
name = "custom_error"
version = "0.1.0"
edition = "2021"
default-run = "custom_error"

[dependencies]
thiserror = "1"
src/custom_error/src/main.rsRust
fn main() {
    println!("This crate has three executable examples.");
    println!();
    println!("  cargo run --bin ex01_define");
    println!("  cargo run --bin ex02_from");
    println!("  cargo run --bin ex03_app");
}

Assignment

Problem 1 — Defining UserError

  • File name: homework_01/src/main.rs
  • Key concepts: thiserror::Error, #[error]

Requirements

  • Define the UserError enum with two variants.
  • Empty — Message: "The name is empty"
  • TooShort { len: usize, min: usize } — Message: "The name is too short (current {len}, minimum {min})"
  • Function ZX0000ZX returns
  • If empty string, Err(UserError::Empty)
  • If less than 2 characters, Err(UserError::TooShort { len, min: 2 })
  • Otherwise, Ok(name.to_string())
  • In main, print the results of "", "a", and "Rust" respectively.

Expected output

"" → The name is empty
"a" → The name is too short (current 1, minimum 2)
"Rust" → Valid: Rust

Hint

  • Character length with name.chars().count().
  • Struct-like variant message: #[error("The name is too short (current {len}, minimum {min})")].

Problem 2 — Sum of Two Inputs + Integrating External Errors

  • File name: homework_02/src/main.rs
  • Key concepts: #[from], automatic conversion of ?

Requirements

  • Define one variant of SumError so that it becomes Parse(#[from] std::num::ParseIntError).
  • Message: "Parsing failed: {0}"
  • Function sum_strings(a: &str, b: &str) -> Result<i32, SumError> returns the sum of two inputs (parse failures propagate automatically).
  • In main, print the results of two calls: ("3", "4") and ("12", "x").

Expected output

("3", "4") → Ok(7)
("12", "x") → Err: Parsing failed: invalid digit found in string

Hint

  • #[derive(Debug, Error)].
  • Use ? like let x: i32 = a.parse()?;.

Check your answers

Try solving it yourself first, then compare your answer with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/Cargo.tomlTOML
[package]
name = "homework_01"
version = "0.1.0"
edition = "2021"

[dependencies]
thiserror = "1"
homework_01/src/main.rsRust
//Key Points:
//- Messages for struct-like variants can be directly interpolated using `{field_name}`.
//- `chars().count()` is a safe way to count multi-byte characters accurately.

use thiserror::Error;

#[derive(Debug, Error)]
enum UserError {
    #[error("The name is empty")]
    Empty,
    #[error("The name is too short (current {len}, minimum {min})")]
    TooShort { len: usize, min: usize },
}

fn validate_name(name: &str) -> Result<String, UserError> {
    if name.is_empty() {
        return Err(UserError::Empty);
    }
    let len = name.chars().count();
    let min = 2;
    if len < min {
        return Err(UserError::TooShort { len, min });
    }
    Ok(name.to_string())
}

fn main() {
    for n in ["", "a", "Rust"] {
        match validate_name(n) {
            Ok(v) => println!("{n:?} → valid: {v}"),
            Err(e) => println!("{n:?} → {e}"),
        }
    }
}
homework_02/Cargo.tomlTOML
[package]
name = "homework_02"
version = "0.1.0"
edition = "2021"

[dependencies]
thiserror = "1"
homework_02/src/main.rsRust
//Key Points:
//- Implementing From in one line with `#[from] ParseIntError` — `?` automatically handles the conversion.
//- The caller only needs to know SumError to catch both stages of parsing failures.

use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum SumError {
    #[error("Parsing failure: {0}")]
    Parse(#[from] ParseIntError),
}

fn sum_strings(a: &str, b: &str) -> Result<i32, SumError> {
    let x: i32 = a.parse()?;
    let y: i32 = b.parse()?;
    Ok(x + y)
}

fn main() {
    let cases = [("3", "4"), ("12", "x")];
    for (a, b) in cases {
        let label = format!("({:?}, {:?})", a, b);
        match sum_strings(a, b) {
            Ok(n) => println!("{label} → Ok({n})"),
            Err(e) => println!("{label} → Err: {e}"),
        }
    }
}

Learn this lesson with a mentor

Work through the Rust track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now