Skip to content
rusthacks.dev
Rust

Result in Rust Explained and How to Handle Both Outcomes

Result is an enum with Ok and Err. Learn the handful of methods that cover most uses and when to match instead of chaining.

8 min read
Summarize in AI
Flat illustration for the post “Result in Rust Explained and How to Handle Both Outcomes”, composed around errors.

Result<T, E> is how Rust says a function can fail. It is an ordinary enum with two variants, Ok(T) and Err(E), and the compiler will not let you read the value inside without deciding what happens to the other case. There are no exceptions, no hidden control flow and no null. This post shows the five ways to handle a result (match, ?, combinators, defaults, and unwrapping), what each is for, how main can return one, and the mistakes that turn a result into a panic or silently swallow an error. Everything compiled on Rust 1.99, edition 2024.

What Result is

The definition in the standard library is two lines:

rust
enum Result<T, E> {
    Ok(T),
    Err(E),
}

A function that reads a number from text returns Result<i32, ParseIntError>: either an integer, or the specific reason it could not make one. The error type is part of the signature, so a caller can see in the type what can go wrong. A result is also marked #[must_use]: ignoring it produces a compiler warning, and that warning is the one to treat as an error in CI.

rust
fn main() {
    let good = "42".parse::<i32>();
    let bad = "4x2".parse::<i32>();
    println!("{good:?}");
    println!("{bad:?}");

    "7".parse::<i32>();
}
Text
Ok(42)
Err(ParseIntError { kind: InvalidDigit })

The last line compiles, and the compiler says what is wrong with it:

Text
warning: unused `Result` that must be used
 --> src/main.rs:2:5
  |
2 |     "7".parse::<i32>();
  |     ^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value

The parse could have failed and the program carried on without looking. If ignoring the result is genuinely intended, let _ = ... says so in the code, and a reviewer can ask why. Deny the lint in CI with #![deny(unused_must_use)] so that nobody forgets a result in a hurry.

Way 1: match, to handle both outcomes explicitly

When each case needs its own handling, match. The compiler checks that both are covered:

rust
use std::fs;
use std::io::ErrorKind;

fn read_setting(path: &str) -> String {
    match fs::read_to_string(path) {
        Ok(text) => text.trim().to_string(),
        Err(e) if e.kind() == ErrorKind::NotFound => "default".to_string(),
        Err(e) => format!("unreadable: {e}"),
    }
}

fn main() {
    println!("{}", read_setting("/definitely/not/here.conf"));
    println!("{}", read_setting("/etc"));
}
Text
default
unreadable: Is a directory (os error 21)

The guard if e.kind() == ErrorKind::NotFound distinguishes the error you expect (no file yet) from the ones you do not (a directory where a file should be). Handling the expected case and passing the rest on is the normal shape of real error handling.

Way 2: the question mark, to pass the error up

Most functions cannot do anything useful with an error except tell their caller. The ? operator does exactly that: on Ok(v) it gives you v; on Err(e) it returns early with the error, converted by From into the function's error type.

rust
use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Missing(&'static str),
    BadNumber(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Missing(k) => write!(f, "missing key {k}"),
            ConfigError::BadNumber(e) => write!(f, "bad number: {e}"),
        }
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadNumber(e)
    }
}

fn lookup<'a>(pairs: &'a [(&str, &str)], key: &'static str) -> Result<&'a str, ConfigError> {
    pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| *v).ok_or(ConfigError::Missing(key))
}

fn port(pairs: &[(&str, &str)]) -> Result<u16, ConfigError> {
    let raw = lookup(pairs, "port")?;
    let n: u16 = raw.parse()?;
    Ok(n)
}

fn main() {
    println!("{:?}", port(&[("port", "8080")]));
    for case in [vec![("host", "x")], vec![("port", "http")], vec![("port", "70000")]] {
        match port(&case) {
            Ok(p) => println!("port {p}"),
            Err(e) => println!("error: {e}"),
        }
    }
}
Text
Ok(8080)
error: missing key port
error: bad number: invalid digit found in string
error: bad number: number too large to fit in target type

Two different failures (a missing key and a malformed number) arrive at the caller as one error type. The one-line ? after raw.parse() relies on From<ParseIntError> for ConfigError; the machinery is explained in From and Into. Writing those enums by hand is routine work that thiserror removes, and the choice between it and anyhow is in anyhow versus thiserror.

Way 3: combinators, to transform without a match

A result has methods that map over either side, which turn four-line matches into one line. The ones that earn their place:

MethodWhat it doesTypical use
map(f)Transforms the Ok valueParse, then wrap in a struct
map_err(f)Transforms the Err valueAdd context or change the error type
and_then(f)Chains a step that can itself failParse, then validate range
or_else(f)Recovers from an error with another attemptTry a second file location
ok()Turns Result into Option, discarding the error"Not found" is fine
unwrap_or(d) / unwrap_or_else(f) / unwrap_or_default()Replaces the error with a valueA fallback that is always right
rust
fn workers(raw: &str) -> Result<usize, String> {
    raw.trim()
        .parse::<usize>()
        .map_err(|e| format!("workers must be a number, got {raw:?}: {e}"))
        .and_then(|n| if (1..=64).contains(&n) { Ok(n) } else { Err(format!("{n} is outside 1..=64")) })
}

fn main() {
    for raw in ["8", " 16 ", "0", "many"] {
        println!("{raw:?} -> {:?}", workers(raw));
    }
    println!("{}", workers("many").unwrap_or(4));
    println!("{:?}", workers("99").ok());
}
Text
"8" -> Ok(8)
" 16 " -> Ok(16)
"0" -> Err("0 is outside 1..=64")
"many" -> Err("workers must be a number, got \"many\": invalid digit found in string")
4
None

Chains like this read as a pipeline of steps where the first failure short-circuits the rest. Past three or four links, a function with ? on separate lines is easier to read and to put a breakpoint in.

Way 4: collecting many results

An iterator of results can be collected into one result. The whole collection fails at the first error, or you split them, depending on what you want:

rust
fn main() {
    let lines = ["10", "20", "x30", "40"];

    let all: Result<Vec<i32>, _> = lines.iter().map(|s| s.parse::<i32>()).collect();
    println!("{all:?}");

    let good: Vec<i32> = lines.iter().filter_map(|s| s.parse().ok()).collect();
    println!("{good:?}");

    let (oks, errs): (Vec<_>, Vec<_>) = lines.iter().map(|s| s.parse::<i32>()).partition(|r| r.is_ok());
    println!("{} ok, {} failed", oks.len(), errs.len());

    let total: Result<i32, _> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>()).sum();
    println!("{total:?}");
}
Text
Err(ParseIntError { kind: InvalidDigit })
[10, 20, 40]
3 ok, 1 failed
Ok(6)

The first form is all-or-nothing and is right for a request body. filter_map(... .ok()) silently drops the bad lines, which is a deliberate decision that should be visible in a comment, not a default. Dropping errors without a trace is the most common way a data pipeline loses rows.

Way 5: unwrapping, and when a panic is right

unwrap() returns the value or panics with the error's debug form. expect("reason") does the same with your message. Both turn a recoverable error into a crash, which is correct in tests, in examples and in one more case, when an error would mean a bug in the program itself rather than bad input:

rust
use std::net::IpAddr;

fn main() {
    let loopback: IpAddr = "127.0.0.1".parse().expect("a literal IP address always parses");
    println!("{loopback}");

    let from_user: Result<IpAddr, _> = "300.1.1.1".parse::<IpAddr>();
    println!("{from_user:?}");

    let n: i32 = "oops".parse().expect("config port must be a number");
    println!("{n}");
}
Text
127.0.0.1
Err(AddrParseError(Ip))
thread 'main' panicked at src/main.rs:10:33:
config port must be a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The first expect documents an invariant (the literal is valid); if it ever fires, the program is wrong. The last one is the misuse: a user can supply that string, and a panic is the wrong response to bad input. Review rule: every unwrap or expect in non-test code needs a reason the failure is impossible, written in the expect message. Panic behaviour and what the exit code looks like are in the unwrap-on-None panic.

A main that returns a Result

Since main may return Result<(), E> where E: Debug, an uncaught error ends the program with exit code 1 and prints the error. That is enough for small tools:

rust
use std::error::Error;
use std::fs;

fn main() -> Result<(), Box<dyn Error>> {
    let text = fs::read_to_string("/definitely/not/here.conf")?;
    println!("{} bytes", text.len());
    Ok(())
}
Text
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Notice the output is the debug form, Error: Os { code: 2, ... }, not the friendly message. For a user-facing tool, print the Display form yourself and set an exit code, as in CLI exit codes.

Where Result is not the right type

If the absence of a value is not an error, use Option: looking up a key that may not be in a map is Option, and calling it a failure invents an error type with nothing to say. If failure is a bug in the program (an index out of range, an arithmetic overflow in debug), the language panics. If there is more than one value worth reporting at the same time, such as the lines in a file that all failed validation, collect them into a vector of errors inside a single error, not one at a time. And across a thread or task boundary, the result of a panic is a JoinError or Err(Box<dyn Any>), not your own error type. The broader strategy for how errors move through a program is in error handling in Rust.

Taggedresult
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.