Rust Errors, Without the Drama

A beginner-friendly conversation on Errors, Results, Options, and beyond.

This post is under construction.

TL;DR

  • Rust has no exceptions: It distinguishes recoverable errors (handled with the Result<T, E> type) and unrecoverable errors (handled by panicking using panic!()) 1. This means we must explicitly handle errors 2.
  • Result<T, E> enum: Represents either success (Ok(T)) or error (Err(E)). Use pattern matching ( match ), or methods like .unwrap()/.expect() (which panic on error) to handle these. Prefer .expect() with a custom message 3
  • ? operator for propagation: To propagate errors upwards without heavy syntax, use the ? operator. It returns the error to the caller if an operation fails. Only works in functions returning a compatible Result<T, E> (or Option ). main() can return a Result<T, E> and use ?
  • Option<T> vs Result<T, E>:
    • Use Option<T> when the absence of a value isn’t an error (e.g. no search result) and no error info is needed.
    • Use Result<T, E> when an operation can fail in an exceptional way and we need to convey an error message or reason 5.
  • When to panic: Reserve panic!() for truly unrecoverable bugs or invalid states (e.g. asserting invariant). If failure is expected or possible in normal operation (e.g. file not found, invalid user input…), return a Result<T, E> instead 7. Library code should avoid panicking on recoverable errors to let the caller decide how to handle them.
  • Custom error types: For complex programs or libraries, define our own custom error types (often as enums) to represent various error kinds in one type. Implementing std::error::Error (via Display and Debug ) for these types or using Box<dyn std::error::Error> can help integrate with the ? operator and allow different error kinds to propagate seamlessly 8

  • Keep in mind
use std::fs::File; // shortcut
use std::io::Read;

pub type Result<T> = std::result::Result<T, Error>; // alias
pub type Error = Box<dyn std::error::Error>;

fn main() -> Result<()> {
    let f = File::open("foo.txt")?;
    let mut data = vec![];
    f.File.read_to_end(&mut data)?;
    Ok(())
}

Let's have a beginner-friendly conversation on Errors, Results, Options, and beyond.

Table of Contents

Why Alice and Bob?

A long time ago (2010, may be) I read a .pdf about the N-Body Simulations. Too bad I can’t find it on the web but here is a copy of volume 1. It was based on Ruby but it was great, easy to follow etc. One thing was that it was written as a conversation between Bob and Alice. Later the code was re-written in Python and the set of .pdf was converted into an expensive book (55$ when I bought my copy). Today (sept 2025) you can find it on AMZN for 28€.


Moving Planets Around

Last week I start reading The Basics of Bitcoins and Blockchains (link to AMZN).


The Basics of Bitcoins and Blockchains

In part 3 there is a section Why Alice and Bob. Believe it or not, I then discovered where they came from.

I like to write in a conversational tone, so let’s imagine a discussion between Bob and Alice and let’s see how it goes…

Introduction: Why Rust Cares About Errors

Alice: I ran my Rust code snippet and it forced me to handle an error – it wouldn’t even compile until I did! What’s going here?

Bob: The compiler (rustc) makes sure we acknowledge and handle errors properly before our code even runs. This helps prevent crashes in production 2.

Alice: There are no exceptions at all?

Bob: Exactly. Rust doesn’t have exceptions. Instead, it has a different model for errors. Essentially, Rust groups errors into two categories: recoverable and unrecoverable 9.

  • Recoverable errors are things we expect might happen and can be dealt with (like a file not found – we might just create the file or use a default). These are handled with the Result<T, E> type.
  • Unrecoverable errors are bugs on our side or unexpected conditions (like indexing past the end of an array – something’s really wrong if that happens). For these cases Rust provides the panic!() macro to stop the program 1.

Alice: So Result<T, E> is for errors I can handle, and panic!() is for the program-halting ones?

Bob: Yes!

  • Think of Result<T, E> as Rust’s way of saying “operation might succeed or fail”. We then decide what to do if it fails.
  • Whereas a panic!() is Rust saying “I can’t deal with this,I must crash now”.

By making error handling explicit with Result, Rust ensures we don’t just ignore errors. It won’t let us compile unless we either handle the Result<T, E> (e.g., check for an error) or explicitly choose to crash (like using .unwrap() which triggers a panic if there’s an error). This leads to more robust programs because we’re less likely to have an error go unnoticed.

Alice: It’s a bit scary that the program can just crash with panic!() though.

Bob: Again, panic!() is for cases that are not supposed to happen like an invariant being broken.And even when a panic!() occurs, Rust will unwind the stack and cleanup (or we can opt to abort immediately). But most of the time, you’ll use Result<T, E> for possible errors and only panic!() on bugs. We’ll talk more about choosing between them later 7.

Alice: Ok… So Rust wants me to handle every error. This will not be fun… How do I actually do that with Result? What does a Result<T, E> look like?

Bob: That’s a good question. We’ll answer it by examining how Result<T, E> works and how to use it, but before that, it’s time to recap and practice a little.

Summary – Introduction

  • Rust requires we handle errors explicitly. Code that can fail must return a Result<T, E> (or Option ), forcing the caller to address the possibility of failure 2.
  • Rust distinguishes recoverable errors (e.g. file not found, invalid input – handled with Result) from unrecoverable errors (bugs like out-of-bounds access – handled with panic!() ) 9.
  • No exceptions are used. This language design decision helps prevent unchecked errors. We either deal with the error or deliberately choose to panic!()/.unwrap(), making error handling clear in the code.

Exercises – Introduction

  1. Identify Error Types: Think of two scenarios in programming:
    • one that would be a recoverable error
    • one that would be an unrecoverable error

    For each scenario, explain whether we would use Rust’s Result<T, E> or a panic!(), and why.

  2. Compile-time Check: Write a Rust program that attempts to open a non-existent file with std::fs::File::open(foo.txt) without handling the Result. Observe the compiler error or warning. Then, fix it by handling the Result<T, E> (for now, we can just use a simple panic!() or print an error message in case of Err). One can read 4

Optional - Setting Up our Development Environment

Good to know

  • The code is on Github
  • I expect either CodeLLDB extension or the Build Tools for Visual Studio to be installed.
    • Both can be installed

I use VSCode under Windows 11 and I already wrote a post about my setup. I don’t know yet if I will need more than one project accompanying this post but I already know the first project does’nt have main.rs file but multiple short sample code in the examples/ directory instead. This is fine but I want to make sure we can quickly modify, build, debug and go step by step in the source code.

Now, having this in mind here is what I do and why.

  • Get the project from GitHub
  • Open /examples/ex00.rs in the editor
  • At the time of writing here is what I see :
  • Just for testing purpose, delete the target/ directory if it exists
  • Press CTRL+SHIFT+B. This should build a debug version of the code
    • Check target/debug/examples/. It should contains ex00.exe
  • Set the cursor on line 5 then press F9
    • This set a breakpoint on line 5.
  • Open de Run & Debug tab on the side (CTRL+SIFT+D)
  • In the list select the option corresponding to our configuration (LLDB or MSVC). See below :
  • Press F5
    • This starts the debug session
    • If needed the application is built (not the case here)
    • The execution stops on line 5. See below :
  • Press F10 to move forward
    • Line 5 is executed
    • On the left hand side, in the Local subset, we can chack that bob is now equal to 5.
  • Press F5 to continue and reach the end of the code

Let’s make a last test

  • Delete the target/ directory
  • exe00.rs should be in the editor with a breakpoint on line 5
  • Press F5
    • The ex00.exe is built
    • The debug session starts
    • Execution is stopped on line 5 as before

The making of:

The secret ingredient lies in ./vscode/task.json and ./vscode/launch.json

.vscode/tasks.json:

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "cargo-build-debug",
            "type": "cargo",
            "command": "build",
            "args": [
                "--example",
                "${fileBasenameNoExtension}",
            ],
            "problemMatcher": [
                "$rustc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            }
        },
        {
            "label": "cargo-build-release",
            "type": "cargo",
            "command": "build",
            "args": [
                "--release",
                "--example",
                "${fileBasenameNoExtension}",

            ],
            "problemMatcher": [
                "$rustc"
            ]
        }
    ]
}
  • In cargo-build-debug, group helps to make the cargo-build-debug task the default one (CTRL+SHIFT+B).
  • Note that since the source code to compile is in examples/ directory we pass --example and the name of the file (e.g. ex00) as arguments.
  • To see the list of tasks use ALT+T the press R
    • Below we can see both tasks : cargo-build-debug and cargo-build-release

.vscode/launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "cppvsdbg",
            "request": "launch",
            "name": "Debug (MSVC)",
            "program": "${workspaceFolder}/target/debug/examples/${fileBasenameNoExtension}.exe",
            "args": [],
            "cwd": "${workspaceFolder}",
            "environment": [
                {
                    "name": "RUST_BACKTRACE",
                    "value": "short"
                }
            ],
            "preLaunchTask": "cargo-build-debug"
        },
        {
            "type": "cppvsdbg",
            "request": "launch",
            "name": "Release (MSVC)",
            "program": "${workspaceFolder}/target/release/examples/${fileBasenameNoExtension}.exe",
            "args": [],
            "cwd": "${workspaceFolder}",
            "environment": [
                {
                    "name": "RUST_BACKTRACE",
                    "value": "short"
                }
            ],
            "preLaunchTask": "cargo-build-release"
        },
        {
            "type": "lldb",
            "request": "launch",
            "name": "Debug (CodeLLDB)",
            "program": "${workspaceFolder}/target/debug/examples/${fileBasenameNoExtension}.exe",
            "args": [],
            "cwd": "${workspaceFolder}",
            "sourceLanguages": ["rust"],
            "preLaunchTask": "cargo-build-debug"
        }
    ]
}
  • The path in program key points to the executable created at the end of the build (do you see ${fileBasenameNoExtension}?)
  • Note the preLaunchTask. This explains why we can press F5 (debug) even if the file is not built. In such case, the task cargo-build-debug is run then the debug session starts.

Solution to question #2 above

Webliography


Back to top

Published on: Sep 20 2025 at 06:00 PM | Last updated: Sep 20 2025 at 06:00 PM

Copyright © 1964-2025 - 40tude