Skip to content

Split View: Rust 열거형·패턴 매칭·에러 처리

|

Rust 열거형·패턴 매칭·에러 처리

들어가며 — null도 예외도 없는 세계

토니 호어는 null 참조를 발명한 것을 "10억 달러짜리 실수"라고 불렀습니다. 대부분의 주류 언어는 아직도 그 실수를 안고 삽니다. 값이 있을 수도, 없을 수도 있는데 타입은 그걸 구분하지 못하니까요. 그래서 NullPointerException이 프로덕션에서 우리를 기다립니다.

예외도 비슷한 문제가 있습니다. 함수 시그니처만 봐서는 이 함수가 어떤 예외를 던지는지, 아니 던지기는 하는지 알 수 없습니다(자바의 checked exception이 이걸 고치려다 대부분 미움만 샀죠). 에러 처리가 타입 시스템 바깥에 있으니, 컴파일러가 "이 에러 처리 안 했는데?"라고 짚어 줄 수가 없습니다.

Rust는 이 둘을 아예 없앴습니다. null 대신 Option, 예외 대신 Result. 둘 다 평범한 **열거형(enum)**이고, 값의 "없음"과 "실패 가능성"을 타입에 담습니다. 그리고 그 열거형을 안전하게 여는 도구가 **패턴 매칭(pattern matching)**입니다. 컴파일러가 "이 경우를 처리 안 했다"고 잡아 주기 때문에, 잊어버리는 게 불가능해집니다.

열거형은 대수적 데이터 타입

다른 언어의 enum은 대개 이름 붙은 정수 상수 집합입니다. Rust의 enum은 훨씬 강력합니다. 각 변형(variant)이 서로 다른 종류의 데이터를 품을 수 있습니다.

enum Shape {
    Circle { radius: f64 },       // 이름 있는 필드
    Rectangle { w: f64, h: f64 }, // 두 필드
    Point,                        // 데이터 없음
}

Shape 값은 원이거나, 사각형이거나, 점이거나 — 정확히 셋 중 하나입니다. 이런 "A이거나 B이거나 C"인 타입을 타입 이론에서는 합 타입(sum type) 혹은 **태그된 유니온(tagged union)**이라 부릅니다. 구조체(struct)가 "A이고 B이고 C"인 곱 타입(product type)이라면, enum은 그 대칭입니다. 둘을 합쳐 **대수적 데이터 타입(algebraic data type, ADT)**이라 하고, 이게 Rust 데이터 모델링의 핵심입니다.

핵심은 각 변형이 자기만의 데이터를 가진다는 점입니다. C 스타일 enum처럼 "종류"만 나타내는 게 아니라, 그 종류에 딱 맞는 데이터를 함께 담습니다. 이게 뒤에 나올 OptionResult를 가능하게 합니다.

Option — null을 타입으로

Option<T>는 "T 값이 있거나, 없거나"를 표현하는 표준 라이브러리 enum입니다. 정의는 놀랄 만큼 단순합니다.

enum Option<T> {
    Some(T), // 값이 있음, 그 값은 T
    None,    // 값이 없음
}

이게 null을 대체합니다. 값이 없을 수 있는 곳에는 T가 아니라 Option<T>를 씁니다. 그러면 컴파일러가 강제로 "없는 경우"를 처리하게 만듭니다.

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

fn main() {
    let user = find_user(1);
    // user는 Option<String> — 바로 문자열처럼 못 씀
    // println!("{}", user.len()); // 컴파일 에러! Option에는 len이 없음
}

null과의 결정적 차이는 이것입니다. null은 어떤 참조 타입에나 몰래 들어올 수 있어서 "이 값이 null일 수 있나?"를 코드만 봐선 알 수 없습니다. 반면 Option<T>는 타입에 명시적으로 드러나 있고, 안의 T를 꺼내려면 반드시 None인 경우를 먼저 처리해야 합니다. "없을 수 있음"이 타입에 적혀 있고, 컴파일러가 그 처리를 강제합니다. 십억 달러짜리 실수가 타입 시스템 안으로 들어온 겁니다.

match — 빠짐없이(exhaustively) 해체하기

Option 안의 값을 꺼내는 정석은 match입니다. match는 값을 패턴들과 대조해서 맞는 가지를 실행하는데, Rust의 결정적 안전장치는 match빠짐없어야(exhaustive) 한다는 것입니다. 가능한 모든 경우를 다루지 않으면 컴파일이 안 됩니다.

fn main() {
    let user = find_user(1);

    match user {
        Some(name) => println!("찾음: {name}"), // Some이면 안의 name을 바인딩
        None => println!("사용자 없음"),         // None인 경우
    }
}

Some(name)에서 nameSome 안에 들어 있던 String을 꺼내 바인딩한 것입니다. 이렇게 패턴이 값의 구조를 해체하면서 동시에 내부 값을 변수로 뽑아내는 걸 **구조 분해(destructuring)**라 합니다.

만약 None 가지를 빼먹으면 이렇게 됩니다.

error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:4:11
  |
4 |     match user {
  |           ^^^^ pattern `None` not covered
  |
  = help: ensure that all possible cases are being handled

이 exhaustiveness 검사가 조용한 위력입니다. 나중에 enum에 변형을 하나 추가하면, 그 enum을 다루는 모든 match가 컴파일 에러를 냅니다. "새 경우를 처리하라"고 컴파일러가 전수 조사를 해 주는 겁니다. 이건 리팩터링 안전망으로서 엄청난 가치가 있습니다. 새 상태를 추가했는데 처리를 깜빡한 곳이 프로덕션에서 터지는 일이 원천 봉쇄됩니다.

모든 경우를 일일이 쓰기 싫으면 와일드카드 _로 "나머지 전부"를 잡을 수 있습니다. 다만 _를 남발하면 exhaustiveness의 이점이 사라지니, 정말 무관심한 나머지에만 쓰는 게 좋습니다.

fn describe(n: i32) -> &'static str {
    match n {
        0 => "영",
        1 | 2 | 3 => "작은 수",      // 여러 패턴을 | 로
        4..=9 => "한 자리",          // 범위 패턴
        _ => "그 외",                // 나머지 전부
    }
}

if let과 let else — 한 경우만 다룰 때

match가 모든 경우를 요구하는 게 과할 때가 있습니다. "Some일 때만 뭔가 하고 None이면 아무것도 안 함" 같은 경우죠. 이럴 때 if let이 간결합니다.

fn main() {
    let config = find_user(1);

    if let Some(name) = config {
        println!("설정된 사용자: {name}"); // Some일 때만 실행
    }
    // None이면 그냥 넘어감 (else 생략 가능)
}

if let Some(name) = config는 "configSome 패턴에 맞으면 name을 바인딩하고 블록을 실행하라"는 뜻입니다. match의 한 가지만 떼어 쓰는 문법 설탕입니다. else도 붙일 수 있습니다.

반대 상황도 흔합니다. "값이 있어야 정상이고, 없으면 여기서 일찍 빠져나간다." 이건 let else가 우아합니다(Rust 1.65부터).

fn greet(id: u32) -> String {
    let Some(name) = find_user(id) else {
        return String::from("손님"); // None이면 여기서 함수 종료
    };
    // 이 아래로는 name이 String으로 확정되어 그냥 쓸 수 있음
    format!("환영합니다, {name}!")
}

let else의 미덕은 성공 경로를 들여쓰기 밖으로 빼낸다는 것입니다. if let으로 성공을 감싸면 코드가 오른쪽으로 계속 밀려나는데(화살표 코드), let else는 실패를 먼저 처리하고 빠져나가서, 나머지 본문이 평평하게 유지됩니다. "가드 절(guard clause)"을 Rust 타입 시스템과 결합한 형태죠.

Result — 실패를 값으로

이제 에러 처리입니다. Result<T, E>는 "성공하면 T, 실패하면 E"를 담는 enum입니다.

enum Result<T, E> {
    Ok(T),  // 성공, 결과는 T
    Err(E), // 실패, 에러는 E
}

예외와의 핵심 차이는 이것입니다. 예외는 함수 밖으로 던져져서 어딘가에서 잡히거나 프로그램을 죽입니다. 반면 Result는 그냥 반환값입니다. 실패 가능성이 함수 시그니처에 -> Result<T, E>적혀 있고, 호출자는 그 값을 받아 Ok인지 Err인지 처리해야 합니다. 에러 처리가 제어 흐름의 숨은 옆길이 아니라, 눈에 보이는 정상적인 값의 흐름이 됩니다.

use std::num::ParseIntError;

fn parse(s: &str) -> Result<i64, ParseIntError> {
    s.parse::<i64>() // parse는 Result를 반환
}

fn main() {
    match parse("42") {
        Ok(n) => println!("숫자: {n}"),
        Err(e) => println!("파싱 실패: {e}"),
    }
}

Rust는 예외가 없으므로, 실패할 수 있는 표준 라이브러리 함수(파일 열기, 문자열 파싱, 네트워크 요청)는 거의 다 Result를 반환합니다. 그리고 Result#[must_use]로 표시되어 있어서, 반환된 Result를 무시하면 컴파일러가 경고합니다. 에러를 조용히 삼키는 게 어렵게 설계된 것입니다.

? 연산자 — 에러 전파의 간결함

Result를 일일이 match로 풀면 코드가 금세 지저분해집니다. 특히 실패할 수 있는 연산을 여러 개 이어 붙일 때요. 그래서 ? 연산자가 있습니다.

?Result(또는 Option)에 붙여서 이렇게 동작합니다. Ok(v)면 안의 v를 꺼내 식의 값으로 쓰고, Err(e)면 그 Err를 현재 함수에서 즉시 return 한다. 즉 "성공하면 계속, 실패하면 이 에러를 위로 던진다"를 한 글자로 표현합니다.

use std::fs;
use std::io;

// ? 없이 — 장황함
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
    let contents = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e), // 실패를 손으로 전파
    };
    Ok(contents.len())
}

// ? 사용 — 같은 동작, 훨씬 간결
fn read_len(path: &str) -> Result<usize, io::Error> {
    let contents = fs::read_to_string(path)?; // 실패하면 자동으로 Err 반환
    Ok(contents.len())
}

두 함수는 정확히 같은 일을 합니다. ?가 그 match-후-조기반환 패턴을 대신해 줄 뿐입니다. 여러 연산을 이을 때 진가가 드러납니다.

fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
    let text = fs::read_to_string(path)?; // io::Error 전파
    let first_line = text.lines().next().unwrap_or("");
    let number: i64 = first_line.trim().parse()?; // ParseIntError 전파
    Ok(number * 2)
}

여기서 ?가 하나 더 해 주는 일이 있습니다. 에러 타입 변환입니다. read_to_stringio::Error를, parseParseIntError를 내는데, 함수 반환 타입은 Box<dyn std::error::Error>입니다. ?From 트레잇을 통해 각 에러를 반환 타입으로 자동 변환합니다. 서로 다른 에러들을 하나의 반환 타입으로 매끄럽게 모아 주는 거죠. 이게 다음 절의 에러 타입 설계와 이어집니다.

주의: ?는 반환 타입이 Result(또는 Option)인 함수 안에서만 쓸 수 있습니다. 반환 타입이 맞아야 Err를 return할 수 있으니까요.

unwrap과 expect — 언제 패닉해도 되는가

Option이나 Result에서 값을 강제로 꺼내는 .unwrap().expect()도 있습니다. Ok/Some이면 값을 주고, Err/None이면 **패닉(panic)**하여 프로그램을 종료합니다.

let n: i64 = "42".parse().unwrap();          // Ok(42) → 42
let m: i64 = "abc".parse().unwrap();         // Err → 패닉!
let k: i64 = "abc".parse()
    .expect("설정값은 정수여야 합니다");       // 패닉 + 이 메시지

unwrap은 편하지만 위험합니다. 실패가 진짜로 불가능한 곳(방금 검증한 값 등)이나, 프로토타입·예제·테스트에서는 괜찮습니다. 하지만 프로덕션 코드의 정상 경로에서 남용하면 안 됩니다. 사용자 입력이나 파일·네트워크처럼 실패할 수 있는 것에 unwrap을 걸면, 그건 예외를 안 잡고 방치하는 것과 다를 바 없습니다. 그럴 땐 ?로 전파하거나 match로 다루세요. expect는 최소한 "왜 여기서 실패가 불가능하다고 봤는지"를 메시지로 남기니, unwrap보다 expect가 낫습니다.

정리하면 panic!(그리고 unwrap)은 회복 불가능한 버그 상황용이고, Result회복 가능한 예상된 실패용입니다. "파일이 없을 수 있다"는 회복 가능(→ Result), "배열 인덱스가 음수다"는 프로그램 로직 버그(→ 패닉)입니다.

thiserror와 anyhow — 실전 에러 타입 설계

표준 라이브러리만으로도 되지만, 실전 프로젝트는 대개 두 크레이트를 씁니다. 역할이 명확히 갈립니다.

thiserror는 라이브러리용입니다. 라이브러리는 호출자가 에러를 종류별로 구분해 처리할 수 있도록, 구체적인 에러 타입을 노출해야 합니다. thiserror는 그 커스텀 에러 enum을 보일러플레이트 없이 만들어 줍니다.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("설정 파일을 읽을 수 없음")]
    Io(#[from] std::io::Error), // #[from]으로 io::Error 자동 변환

    #[error("잘못된 포트 번호: {0}")]
    InvalidPort(u16),           // 데이터를 품는 변형

    #[error("필수 키 누락: {key}")]
    MissingKey { key: String },
}

#[derive(Error)]std::error::Error 트레잇 구현을, #[error("...")]가 사람이 읽을 메시지(Display)를 자동 생성합니다. #[from]을 붙인 변형은 그 에러 타입에서 자동 변환을 지원하므로, 함수 안에서 ? 하나로 io::ErrorConfigError::Io로 바뀝니다. 라이브러리 사용자는 이 enum을 match해서 "IO 문제인지, 포트가 잘못됐는지"를 구분해 대응할 수 있습니다.

anyhow는 애플리케이션용입니다. 최종 애플리케이션(CLI, 서버)의 상당수 지점에서는 에러를 종류별로 구분할 필요 없이 "실패했고, 이런 맥락이었다"만 알면 됩니다. anyhow::Error는 어떤 에러든 담는 단일 타입이라, 다양한 에러를 ?로 자유롭게 섞어 전파할 수 있습니다.

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> { // anyhow::Result
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("설정 파일 열기 실패: {path}"))?; // 맥락 추가
    let port_line = text.lines().next()
        .context("파일이 비어 있음")?;
    Ok(port_line.to_string())
}

anyhow의 강점은 .context()입니다. 에러에 "무엇을 하려다 실패했는지" 맥락을 층층이 쌓아 주므로, 최종 에러 메시지가 "파일 없음" 같은 저수준 메시지가 아니라 "설정 파일 열기 실패: /etc/app.conf → 파일 없음" 같은 추적 가능한 사슬이 됩니다.

정리하면: 라이브러리를 만든다면 thiserror로 구체적 에러 타입을 노출하고, 애플리케이션을 만든다면 anyhow로 맥락을 붙여 편하게 전파하세요. 이 두 크레이트의 조합이 오늘날 Rust 에러 처리의 사실상 표준입니다.

손으로 익히기

match의 exhaustiveness나 ?의 전파 동작은 직접 컴파일해 보며 에러를 만나 봐야 확실히 이해됩니다. Rust 학습 랩에서 Option·Result 해체와 ? 연산자를 다양한 시나리오로 실험해 보면, "컴파일러가 나 대신 경우를 빠뜨리지 않게 지켜본다"는 감각이 손에 붙습니다.

마치며

Rust의 에러 처리 철학은 한 문장으로 요약됩니다. 실패는 값이다. null과 예외라는 두 개의 숨은 옆문을 닫고, "없음"과 "실패"를 Option·Result라는 평범한 값으로 타입에 담습니다. 그 값을 여는 열쇠가 패턴 매칭이고, 컴파일러의 exhaustiveness 검사가 "이 경우를 잊었다"를 대신 잡아 줍니다.

그 대가로 처음엔 코드가 조금 장황해 보일 수 있습니다. 하지만 ? 연산자가 그 장황함의 대부분을 걷어 가고, 남는 것은 어디서 무엇이 실패할 수 있는지가 코드에 정직하게 드러난 프로그램입니다. 프로덕션 새벽에 NullPointerException 스택트레이스를 들여다보는 대신, 컴파일 타임에 "이 경우 처리 안 했어요"를 듣는 쪽을 택하는 것 — 그게 이 설계의 거래입니다.

참고 자료

Rust Enums, Pattern Matching, and Error Handling

Introduction — A World With No Null and No Exceptions

Tony Hoare called inventing the null reference his "billion-dollar mistake." Most mainstream languages still live with that mistake: a value may or may not be there, yet the type can't tell the difference. So a NullPointerException waits for us in production.

Exceptions have a similar problem. From a function's signature alone, you can't tell which exceptions it throws — or whether it throws at all (Java's checked exceptions tried to fix this and mostly earned resentment). Because error handling lives outside the type system, the compiler can't point out "you didn't handle this error."

Rust removes both. Instead of null, Option; instead of exceptions, Result. Both are ordinary enums, and they put the "absence" of a value and the "possibility of failure" into the type. The tool for opening those enums safely is pattern matching. Because the compiler catches "you didn't handle this case," forgetting becomes impossible.

Enums Are Algebraic Data Types

In most languages, an enum is a set of named integer constants. Rust's enum is far more powerful: each variant can carry a different kind of data.

enum Shape {
    Circle { radius: f64 },       // named field
    Rectangle { w: f64, h: f64 }, // two fields
    Point,                        // no data
}

A Shape value is a circle, or a rectangle, or a point — exactly one of the three. This kind of "A or B or C" type is called a sum type or tagged union in type theory. If a struct is a product type ("A and B and C"), an enum is its mirror image. Together they're called algebraic data types (ADTs), and they're at the core of data modeling in Rust.

The key point is that each variant carries its own data. Unlike a C-style enum that only represents a "kind," it packs exactly the data that fits that kind. This is what makes Option and Result, coming up next, possible.

Option — Null as a Type

Option<T> is a standard-library enum expressing "there's a T value, or there isn't." Its definition is astonishingly simple.

enum Option<T> {
    Some(T), // there is a value, and it's a T
    None,    // there is no value
}

This replaces null. Where a value may be absent, you write Option<T> instead of T. Then the compiler forces you to handle the "absent" case.

fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

fn main() {
    let user = find_user(1);
    // user is Option<String> — you can't use it directly as a string
    // println!("{}", user.len()); // compile error! Option has no len
}

The decisive difference from null is this. Null can sneak into any reference type, so you can't tell from the code whether "this value might be null." An Option<T>, by contrast, is explicit in the type, and to get the inner T out you must first handle the None case. "Might be absent" is written in the type, and the compiler forces you to handle it. The billion-dollar mistake has been pulled inside the type system.

match — Taking It Apart Exhaustively

The canonical way to get a value out of an Option is match. match compares a value against patterns and runs the arm that fits, and Rust's decisive safety feature is that match must be exhaustive: if you don't cover every possible case, it won't compile.

fn main() {
    let user = find_user(1);

    match user {
        Some(name) => println!("found: {name}"), // if Some, bind the inner name
        None => println!("no user"),             // the None case
    }
}

In Some(name), name binds the String that was inside the Some. A pattern that takes apart a value's structure while pulling out its inner values into variables like this is called destructuring.

Leave out the None arm and you get this.

error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:4:11
  |
4 |     match user {
  |           ^^^^ pattern `None` not covered
  |
  = help: ensure that all possible cases are being handled

This exhaustiveness check is a quiet superpower. If you later add a variant to an enum, every match that handles that enum produces a compile error. The compiler runs a census telling you to "handle the new case." As a refactoring safety net, this is enormously valuable: it becomes impossible to add a new state and forget to handle it somewhere that then blows up in production.

If you don't want to spell out every case, the wildcard _ catches "everything else." But leaning on _ throws away the benefit of exhaustiveness, so use it only for a truly don't-care remainder.

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 | 2 | 3 => "small",       // multiple patterns with |
        4..=9 => "single digit",    // range pattern
        _ => "other",               // everything else
    }
}

if let and let else — When You Care About One Case

Sometimes requiring every case is overkill — like "do something only when it's Some, and nothing when it's None." Here if let is concise.

fn main() {
    let config = find_user(1);

    if let Some(name) = config {
        println!("configured user: {name}"); // runs only when Some
    }
    // if None, just move on (the else is optional)
}

if let Some(name) = config means "if config matches the Some pattern, bind name and run the block." It's syntactic sugar for one arm of a match. You can attach an else too.

The opposite situation is common as well: "a value must be present for things to be normal, and if it's absent we bail out early here." For that, let else is elegant (since Rust 1.65).

fn greet(id: u32) -> String {
    let Some(name) = find_user(id) else {
        return String::from("guest"); // if None, return from the function here
    };
    // below this line, name is definitely a String, usable directly
    format!("Welcome, {name}!")
}

The virtue of let else is that it lifts the success path out of the indentation. Wrap success in an if let and the code keeps drifting to the right (arrow code), whereas let else handles failure first and bails, keeping the rest of the body flat. It's a "guard clause" combined with Rust's type system.

Result — Failure as a Value

Now for error handling. Result<T, E> is an enum holding "a T on success, an E on failure."

enum Result<T, E> {
    Ok(T),  // success, the result is a T
    Err(E), // failure, the error is an E
}

The key difference from exceptions is this. An exception is thrown out of a function to be caught somewhere or to kill the program. A Result, by contrast, is just a return value. The possibility of failure is written into the function's signature as -> Result<T, E>, and the caller receives that value and must handle whether it's Ok or Err. Error handling stops being a hidden side path of control flow and becomes a visible, ordinary flow of values.

use std::num::ParseIntError;

fn parse(s: &str) -> Result<i64, ParseIntError> {
    s.parse::<i64>() // parse returns a Result
}

fn main() {
    match parse("42") {
        Ok(n) => println!("number: {n}"),
        Err(e) => println!("parse failed: {e}"),
    }
}

Since Rust has no exceptions, nearly every fallible standard-library function (opening a file, parsing a string, making a network request) returns a Result. And Result is marked #[must_use], so the compiler warns you if you ignore a returned Result. It's designed to make silently swallowing an error hard.

The ? Operator — The Brevity of Error Propagation

Unwinding every Result with a match gets messy fast, especially when you chain several fallible operations. That's what the ? operator is for.

You put ? after a Result (or Option), and it works like this: if it's Ok(v), take the inner v and use it as the expression's value; if it's Err(e), immediately return that Err from the current function. In other words, "continue on success, throw this error upward on failure" in a single character.

use std::fs;
use std::io;

// Without ? — verbose
fn read_len_verbose(path: &str) -> Result<usize, io::Error> {
    let contents = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(e), // propagate failure by hand
    };
    Ok(contents.len())
}

// With ? — same behavior, far more concise
fn read_len(path: &str) -> Result<usize, io::Error> {
    let contents = fs::read_to_string(path)?; // on failure, auto-return Err
    Ok(contents.len())
}

The two functions do exactly the same thing; ? just stands in for that match-then-early-return pattern. Its real value shows when you chain operations.

fn process(path: &str) -> Result<i64, Box<dyn std::error::Error>> {
    let text = fs::read_to_string(path)?; // propagate io::Error
    let first_line = text.lines().next().unwrap_or("");
    let number: i64 = first_line.trim().parse()?; // propagate ParseIntError
    Ok(number * 2)
}

There's one more thing ? does here: error type conversion. read_to_string yields an io::Error and parse yields a ParseIntError, but the function's return type is Box<dyn std::error::Error>. Via the From trait, ? converts each error into the return type automatically, smoothly gathering different errors into a single return type. This connects to error type design in the next section.

A caveat: ? can only be used inside a function whose return type is Result (or Option) — the return type has to match so it can return the Err.

unwrap and expect — When Is It OK to Panic?

There's also .unwrap() and .expect(), which force a value out of an Option or Result. On Ok/Some they give you the value; on Err/None they panic and terminate the program.

let n: i64 = "42".parse().unwrap();       // Ok(42) → 42
let m: i64 = "abc".parse().unwrap();      // Err → panic!
let k: i64 = "abc".parse()
    .expect("the config value must be an integer"); // panic + this message

unwrap is convenient but dangerous. It's fine where failure is truly impossible (a value you just validated), or in prototypes, examples, and tests. But don't lean on it in the normal path of production code. Calling unwrap on something that can fail — user input, a file, the network — is no better than leaving an exception uncaught. In those cases, propagate with ? or handle it with match. At least expect leaves a message explaining "why I believed failure was impossible here," so prefer expect over unwrap.

To summarize, panic! (and unwrap) is for unrecoverable bug situations, while Result is for recoverable, expected failures. "The file might not exist" is recoverable (→ Result); "the array index is negative" is a program-logic bug (→ panic).

thiserror and anyhow — Designing Real-World Error Types

The standard library alone works, but real projects usually reach for two crates, with clearly divided roles.

thiserror is for libraries. A library should expose concrete error types so callers can handle errors by kind. thiserror generates that custom error enum with no boilerplate.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("could not read config file")]
    Io(#[from] std::io::Error), // #[from] auto-converts io::Error

    #[error("invalid port number: {0}")]
    InvalidPort(u16),           // a variant carrying data

    #[error("missing required key: {key}")]
    MissingKey { key: String },
}

#[derive(Error)] generates the std::error::Error trait implementation, and #[error("...")] generates the human-readable message (Display). A variant with #[from] supports automatic conversion from that error type, so a single ? inside a function turns an io::Error into ConfigError::Io. Users of the library can match on this enum to distinguish "an IO problem" from "a bad port" and respond accordingly.

anyhow is for applications. At many points in a final application (a CLI, a server) you don't need to distinguish errors by kind — you just need to know "it failed, and here's the context." anyhow::Error is a single type that holds any error, so you can freely mix and propagate different errors with ?.

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> { // anyhow::Result
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("failed to open config file: {path}"))?; // add context
    let port_line = text.lines().next()
        .context("file is empty")?;
    Ok(port_line.to_string())
}

The strength of anyhow is .context(). It stacks context — "what I was trying to do when it failed" — layer by layer, so the final error message becomes not a low-level "file not found" but a traceable chain like "failed to open config file: /etc/app.conf → file not found."

In short: if you're building a library, expose concrete error types with thiserror; if you're building an application, propagate conveniently with anyhow and its context. The combination of these two crates is the de facto standard for Rust error handling today.

Learning It With Your Hands

The exhaustiveness of match and the propagation behavior of ? really click only after you compile them yourself and hit the errors. In the Rust Learning Lab, experimenting with Option/Result destructuring and the ? operator across various scenarios gives you the feel that "the compiler is watching so I don't miss a case."

Wrapping Up

Rust's error-handling philosophy can be summed up in one sentence: failure is a value. It closes the two hidden side doors of null and exceptions and puts "absence" and "failure" into the type as ordinary values, Option and Result. Pattern matching is the key that opens those values, and the compiler's exhaustiveness check catches "you forgot this case" on your behalf.

The price is that the code can look a little verbose at first. But the ? operator sweeps away most of that verbosity, and what remains is a program in which where and what can fail is honestly visible in the code. Rather than staring at a NullPointerException stack trace at 3 a.m. in production, you choose to hear "you didn't handle this case" at compile time — that's the trade this design makes.

References