Group
← Back
Part 2 of 6 · Rust for Swift devs

Rust for Swift devs - Error handling

March 10, 2020RustSwiftSystems

Most of the translation between Swift's error handling and Rust's is mechanical. Swift threads failures through throws and try; Rust threads them through a regular generic enum, Result<T, E>, and a single sigil, ?. The control flow looks different on the page, but the underlying model is the same: a function declares in its signature that it can fail, the caller is forced to acknowledge that, and propagation is one keyword away.

What's worth slowing down on is narrower. Rust pins the error type into the signature, where Swift's throws only says "this can fail". Conversions between error types are explicit and routed through the From trait, with ? performing the conversion at the propagation site. And the ecosystem has converged on two crates, thiserror and anyhow, that occupy distinct niches: one for libraries that want a precise typed error, one for applications that just need everything to bubble up to main.

Result<T, E> is the new throws

A Swift function that can fail looks like this:

enum ParseError: Error {
    case empty
    case notPositive
}

func parsePositiveInt(_ s: String) throws -> Int {
    if s.isEmpty { throw ParseError.empty }
    guard let n = Int(s), n > 0 else { throw ParseError.notPositive }
    return n
}

The Rust equivalent returns a Result<T, E>:

#[derive(Debug)]
enum ParseError {
    Empty,
    NotPositive,
}

fn parse_positive_int(s: &str) -> Result<i64, ParseError> {
    if s.is_empty() {
        return Err(ParseError::Empty);
    }
    let n: i64 = match s.parse() {
        Ok(n) => n,
        Err(_) => return Err(ParseError::NotPositive),
    };
    if n <= 0 {
        return Err(ParseError::NotPositive);
    }
    Ok(n)
}

Result<T, E> is just an enum from the standard library:

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

There are no special cases. Success and failure are ordinary enum variants, and Result is matched, mapped, and passed around with the same machinery as any other enum from the previous post. The function doesn't "throw"; it returns a value that happens to encode either a success or a failure.

Several things follow from that. Most importantly, the error type is in the signature. Swift's throws only tells the caller that a function can fail, never how; any Error-conforming value can be thrown, so reading the signature gives no information about which failures are actually reachable. Rust pins the error type into the return type, and a function that returns Result<i64, ParseError> cannot, at the type level, fail with an io::Error or any other unrelated error. That's the single biggest semantic shift coming from Swift.

Errors being values, rather than a control-flow construct, is the second one. Once you have a Result, you can store it in a variable, return it from a closure, push it into a Vec, pattern-match on it, or transform its contents with combinators (Result::map, Result::map_err, Result::and_then). Swift's throws is a control-flow construct first; you have to wrap a throwing call in Result { try ... } to treat the failure as data. Rust started there.

There's also no language-level try/catch. Where Swift uses do { try ... } catch { ... }, Rust uses an ordinary match. The propagation shorthand in the next section gets you back to one-line ergonomics, but the underlying mechanism is still pattern matching on a value.

Result and Option are siblings. Option<T> covers the "no value" case from the previous post; Result<T, E> covers the "value or error" case. Both implement the same propagation shorthand, both have similar combinator surfaces, and converting between them is a one-liner (Result::ok discards the error and yields Option<T>; Option::ok_or(err) attaches an error and yields Result<T, E>). When the caller needs to distinguish "missing" from "failed", they reach for Result. When "missing" is the only failure mode, they reach for Option.

From match to ?

The parser above propagates errors with an explicit match, which gets verbose quickly. Most calls into a fallible function end up shaped like this:

let n: i64 = match s.parse() {
    Ok(n) => n,
    Err(_) => return Err(ParseError::NotPositive),
};

The ? operator is the short form:

let n: i64 = s.parse().map_err(|_| ParseError::NotPositive)?;

Mechanically, ? placed after a Result-typed expression does one of two things. If the value is Ok(t), it unwraps the inner t and the expression continues. If the value is Err(e), it returns from the enclosing function with Err(e). The compiler enforces that the enclosing function returns a Result<_, E> whose error type can absorb e; if it cannot, the code doesn't compile and the next section's From machinery is the answer.

The .map_err(...) step rewrites the standard library's ParseIntError into the domain ParseError::NotPositive so the types line up. The next section shows how a single From impl lets ? perform that rewrite without an explicit map_err.

With ? in hand, the parser shrinks:

fn parse_positive_int(s: &str) -> Result<i64, ParseError> {
    if s.is_empty() {
        return Err(ParseError::Empty);
    }
    let n: i64 = s.parse().map_err(|_| ParseError::NotPositive)?;
    if n <= 0 {
        return Err(ParseError::NotPositive);
    }
    Ok(n)
}

The Swift caller of a throwing function annotates each fallible call site with try:

func reportLargest(_ inputs: [String]) throws -> Int {
    var best = 0
    for s in inputs {
        let n = try parsePositiveInt(s)
        if n > best { best = n }
    }
    return best
}

The Rust equivalent annotates each fallible call site with ?:

fn report_largest(inputs: &[String]) -> Result<i64, ParseError> {
    let mut best = 0;
    for s in inputs {
        let n = parse_positive_int(s)?;
        if n > best { best = n; }
    }
    Ok(best)
}

Read positionally: try precedes the call, ? follows it. The semantics are otherwise identical. A failure short-circuits out of the enclosing function and the caller sees the failure as a regular returned Err.

Swift's try has two modifier forms with direct Rust analogues:

SwiftRustBehaviour on failure
try exprexpr?Propagate the error to the caller.
try? exprexpr.ok()Discard the error, yield an optional value.
try! exprexpr.unwrap()Trap the program if the call fails.

try? and .ok() both throw away the error and expose only success-or-nothing. try! and .unwrap() both turn failure into program termination; the line between recoverable failure and unrecoverable panic is the topic of the final section.

The ? operator extends to Option as well as Result. An Option<T> short-circuits on None:

fn first_word_length(s: &str) -> Option<usize> {
    let first = s.split_whitespace().next()?;
    Some(first.len())
}

If next() returns None, the function returns None immediately. Swift's nearest equivalent is the optional chain:

func firstWordLength(_ s: String) -> Int? {
    return s.split(separator: " ").first?.count
}

The two forms read differently but accomplish the same thing: short-circuit the rest of the computation when the value is missing.

The structural advantage of ? shows in longer chains. Because ? is postfix, you append it to any sub-expression that returns Result or Option, keep operating on the unwrapped value, and append another ? further along when something else can fail.

let port: u16 = std::fs::read_to_string("port.txt")?.trim().parse()?;

The first ? propagates an IO error from reading the file. .trim() runs on the resulting String. The second ? propagates a parse error from converting the trimmed text. Both errors flow into the function's return type via the From machinery covered next.

Swift's try is prefix and covers an entire expression. That works for chains where every fallible step throws, but the moment one step in the chain models failure with an Optional instead, the chain has to switch syntax (?. for the optional, try for the throw) or split across statements. Rust's ? collapses both failure shapes onto a single postfix operator.

Defining your own error type

The Swift side is familiar:

enum HttpError: Error {
    case timeout
    case statusCode(Int)
    case decoding(Error)
}

Swift's Error protocol has no user-facing requirements; declaring : Error is enough, and the compiler synthesises everything an enum needs to conform. Localised messages and other niceties come from a separate LocalizedError protocol that's opt-in.

The Rust side has more pieces. std::error::Error is the trait error types implement, and it's not empty:

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { None }
}

(Several deprecated methods omitted.)

Error has Debug and Display as supertrait bounds, so a type implementing Error must also implement those. In practice that means three pieces: Debug, almost always via #[derive(Debug)], used by unwrap and {:?} formatting; Display, implemented manually, the human-readable message that surfaces when the error is printed; and Error itself, whose default body suffices for most cases. Override source() if your error wraps a lower-level cause so callers and logging libraries can walk the chain.

A complete custom error type looks like this:

use std::fmt;

#[derive(Debug)]
enum HttpError {
    Timeout,
    StatusCode(u16),
    Decoding(serde_json::Error),
}

impl fmt::Display for HttpError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            HttpError::Timeout => write!(f, "request timed out"),
            HttpError::StatusCode(code) => write!(f, "unexpected status code: {}", code),
            HttpError::Decoding(_) => write!(f, "failed to decode response body"),
        }
    }
}

impl std::error::Error for HttpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            HttpError::Decoding(inner) => Some(inner),
            _ => None,
        }
    }
}

The Display impl is the public-facing message, the equivalent of Swift's LocalizedError.errorDescription. Whatever you write here is what gets printed when the error surfaces through {} formatting, or through tools that want a one-line description.

source() returns the underlying cause if there is one. Returning the wrapped serde_json::Error lets a caller, or a logging library, walk the causal chain from HttpError::Decoding down to the raw decoding failure. Swift offers similar reach through (error as NSError).underlyingErrors, but it routes through NSError's reflective machinery rather than a typed trait method.

The boilerplate is deliberate. Each variant of the enum needs a corresponding arm in the Display match, and each wrapped error needs an arm in source(). The shape is the same for every project, which is exactly the mechanical repetition that thiserror will offload in section 5.

A "library-quality" error type means callers can do three things with it. They can match on individual failure modes and react differently to each:

match send_request(url) {
    Ok(body) => process(body),
    Err(HttpError::Timeout) => retry_with_backoff(),
    Err(HttpError::StatusCode(c)) if c >= 500 => retry_with_backoff(),
    Err(other) => log(&other),
}

That's the payoff for using a typed enum rather than a generic boxed error like Box<dyn Error> or anyhow::Error. It's also the property anyhow gives up in section 6 in exchange for ergonomic absorption of any error.

They can walk the causal chain. A generic logging routine, written without any knowledge of HttpError, can call source() repeatedly to print the full chain:

fn report(err: &dyn std::error::Error) {
    eprintln!("error: {}", err);
    let mut cause = err.source();
    while let Some(e) = cause {
        eprintln!("  caused by: {}", e);
        cause = e.source();
    }
}

For an HttpError::Decoding(json_err), this prints the HttpError's Display line followed by the raw serde_json line and column. The function takes &dyn Error, so a caller passes an HttpError reference directly and Rust coerces it. The same value is concrete enough to match on at one site and erased enough to log through a generic helper at another.

And they can absorb lower-level errors via From, which is what lets ? rewrite errors at the propagation site without explicit .map_err(...) calls. That's the next section.

Converting between errors with From

The parse_positive_int example earlier had a single fallible call (s.parse()), which ? and .map_err(...) handled together. A real function usually calls several fallible operations, each with its own error type. Take a stripped-down HTTP client built on the previous HttpError, extended with a Network(std::io::Error) variant for IO failures:

#[derive(Debug)]
enum HttpError {
    Timeout,
    StatusCode(u16),
    Network(std::io::Error),
    Decoding(serde_json::Error),
}

The Display and Error impls follow the same pattern as before and are omitted. A send_request function might look like this without From:

fn send_request(url: &str) -> Result<Vec<u8>, HttpError> {
    let stream = TcpStream::connect(url)
        .map_err(HttpError::Network)?;
    let response = read_to_end(stream)
        .map_err(HttpError::Network)?;
    let body = decode_body(response)
        .map_err(HttpError::Decoding)?;
    Ok(body)
}

Three fallible calls, three explicit .map_err(...) arms, each wrapping a low-level error into an HttpError variant. The rewrites are short and identical in shape, which is exactly what the From trait abstracts over.

From<T> is the standard library's conversion trait:

pub trait From<T> {
    fn from(value: T) -> Self;
}

Read it as: MyType: From<T> means "you can build a MyType from a T". An impl looks like this:

impl From<std::io::Error> for HttpError {
    fn from(e: std::io::Error) -> Self {
        HttpError::Network(e)
    }
}

impl From<serde_json::Error> for HttpError {
    fn from(e: serde_json::Error) -> Self {
        HttpError::Decoding(e)
    }
}

The payoff is that ? knows about From. When you write expr? and expr has type Result<T, E1> while the enclosing function returns Result<_, E2>, the compiler looks for E2: From<E1>. If the impl exists, ? calls E2::from(e1) to convert at the propagation site, and the .map_err(...) calls disappear:

fn send_request(url: &str) -> Result<Vec<u8>, HttpError> {
    let stream = TcpStream::connect(url)?;
    let response = read_to_end(stream)?;
    let body = decode_body(response)?;
    Ok(body)
}

Each ? now does two things at once: short-circuits on Err and converts the error type using the relevant From impl. The function reads as a sequence of straight-line operations, with the failure handling delegated to the type system.

Into is the symmetric trait, and it comes for free. Any T where U: From<T> automatically gets Into<U>. You write one From impl and the compiler hands you Into on the source side. It matters mostly for generic bounds, where T: Into<HttpError> reads more naturally than HttpError: From<T>.

The orphan rule still applies. From the type-system post: an impl Trait for Type requires either the trait or the type to be local to your crate. From<io::Error> for HttpError is fine because HttpError is yours. From<io::Error> for std::process::Output would not compile because both sides are foreign.

The Swift parallel exists but is manual. The closest equivalent uses a do block that catches each lower-level error type and rethrows as the domain error:

func sendRequest(url: String) throws -> Data {
    do {
        let stream = try TcpStream.connect(url: url)
        let response = try readToEnd(stream)
        let body = try decodeBody(response)
        return body
    } catch let e as IOError {
        throw HttpError.network(e)
    } catch let e as DecodingError {
        throw HttpError.decoding(e)
    }
}

The substantive difference is automation. Both languages can rewrite error types at the propagation boundary. Rust does it through a trait the language wires into ?; Swift does it through catch clauses the developer writes once per error type the block might produce. Rust's form scales better when many call sites share the same conversions. Swift's form keeps each conversion visible at the boundary.

From impls written by hand are short, often a single-variant wrap like HttpError::Network(e), but each lower-level error needs its own. The next section covers thiserror, which generates the entire Display / Error / From set from attributes on the enum.

thiserror for libraries

The boilerplate from the previous sections is short per piece but repetitive across an enum: one Display arm per variant, one source() arm per wrapped error, one From impl per absorbed type. thiserror is a third-party crate that collapses all of this into attributes on the enum itself.

The HttpError variants from before (Timeout, StatusCode(u16), Network(io::Error), Decoding(serde_json::Error)), implemented by hand, need a Display impl with four arms, an Error impl with two source() arms (one each for Network and Decoding, the only variants that wrap a lower-level cause), and two From impls so ? can absorb io::Error and serde_json::Error automatically. Roughly 30 lines of impls.

The same type with thiserror:

use thiserror::Error;

#[derive(Debug, Error)]
enum HttpError {
    #[error("request timed out")]
    Timeout,

    #[error("unexpected status code: {0}")]
    StatusCode(u16),

    #[error("network error")]
    Network(#[from] std::io::Error),

    #[error("failed to decode response body")]
    Decoding(#[from] serde_json::Error),
}

Three attribute mechanics replace three impl blocks. #[derive(Error)] generates the std::error::Error impl. Variants that wrap a #[from] field also get a source() arm, so the chain walker from earlier keeps working. #[error("...")] generates the Display impl, with format strings that support positional arguments ({0} for tuple fields) and named arguments for struct-style variants. #[from] on a variant's wrapped field generates both the From impl on the enum and the source() arm, so the variant absorbs the inner error and exposes it through the causal chain.

Both forms produce identical machine code. thiserror's output is a regular impl block that the compiler sees just like the hand-written version, so there's no runtime cost, no extra allocation, and no dispatch overhead. The error type stays a concrete enum, and callers retain the variant matching from earlier:

match send_request(url) {
    Ok(body) => process(body),
    Err(HttpError::StatusCode(c)) if c >= 500 => retry_with_backoff(),
    _ => give_up(),
}

That's the pitch for libraries. The error type is precise enough that callers can branch on individual failure modes, the Error trait's chain walking continues to work, and the maintenance cost of adding a new variant is one line plus its attributes.

The Swift parallel exists at the language level rather than as a library. The Error protocol in Swift is empty (any type adopts it just by declaring : Error), and LocalizedError extends it with optional computed properties like errorDescription for human-readable messages:

enum DataError: LocalizedError {
    case notFound(id: String)
    case parseFailed

    var errorDescription: String? {
        switch self {
        case .notFound(let id): return "Item \(id) not found"
        case .parseFailed:      return "Parsing failed"
        }
    }
}

The Rust compiler does no equivalent synthesis. The Error trait has methods, and Display is a separate trait, both of which thiserror generates from attributes on the enum. The end-user experience is similar; the path to get there differs.

#[from] and #[source] are not interchangeable, and the choice between them comes up whenever a single error type wraps multiple variants of the same underlying error. #[from] generates a From<T> impl on the enum, which is what ? uses at propagation sites. #[source] is narrower: it marks a field as the underlying cause for Error::source() chain walkers, but generates no conversion. The case it solves is two variants that share an inner error type, where #[from] on both would emit conflicting From<T> impls and the compiler would reject the enum:

#[derive(Debug, Error)]
enum AppError {
    #[error("failed to read config: {0}")]
    ReadConfig(#[source] std::io::Error),

    #[error("failed to write log: {0}")]
    WriteLog(#[source] std::io::Error),
}

fn run() -> Result<(), AppError> {
    std::fs::read_to_string("config.json")
        .map_err(AppError::ReadConfig)?;

    std::fs::write("app.log", "ready\n")
        .map_err(AppError::WriteLog)?;

    Ok(())
}

#[source] keeps the inner error reachable through Error::source() and forces the call site to disambiguate via map_err. The trade-off pays off when the variants represent genuinely distinct failure modes that happen to share an error type.

The #[error("...")] format string is a real format! template. Three styles cover the variant shapes:

#[derive(Debug, Error)]
enum DataError {
    #[error("item {id} not found in {table}")]
    NotFound { id: u64, table: String },

    #[error("parse failed at line {0}, column {1}")]
    Parse(usize, usize),

    #[error("permission denied")]
    Forbidden,
}

Named arguments ({id}, {table}) reference fields of struct-style variants. Positional arguments ({0}, {1}) reference tuple-style fields. Unit variants take a static string. Every reference is resolved at macro-expansion time, so a misspelt field name or an out-of-bounds positional index fails to compile rather than slipping through to runtime.

thiserror is the standard choice for libraries that want their error type to be part of their public API: callers can match on variants, walk the chain, and program against typed failure modes. The next section covers anyhow, which takes the opposite trade-off for application code that doesn't need typed errors.

anyhow for applications

thiserror makes typed enums cheap, but the typing itself is sometimes the wrong shape for the problem. A binary that reads config, opens files, talks to HTTP, and writes results doesn't gain much from carrying a hand-built enum that tracks every possible failure mode. The error path is the same in every case: log the problem with enough context to debug it, exit non-zero. For that workload the right tool is anyhow.

anyhow::Error is a type-erased, heap-boxed error that wraps any value implementing Error + Send + Sync + 'static. The conversion that makes ? work is built into the type, so any error in scope flows through propagation without explicit From impls or attribute derives:

use anyhow::Result;

fn load_config() -> Result<Config> {
    let text = std::fs::read_to_string("config.json")?;     // io::Error
    let config: Config = serde_json::from_str(&text)?;      // serde_json::Error
    Ok(config)
}

The Result<Config> in the signature is anyhow::Result<Config>, a one-parameter alias for std::result::Result<Config, anyhow::Error>. Both the io::Error and the serde_json::Error are erased into anyhow::Error at the ? site without the function declaring any of them. The Swift parallel is throws itself: the function declares "this can fail" without naming what with.

.context() for the human-readable trail

The cost of erasure is that callers can no longer match on variants. The benefit, in exchange, is that context can be attached at every layer the error passes through. .context() is anyhow's flagship method:

use anyhow::{Context, Result};

fn load_config(path: &Path) -> Result<Config> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config at {}", path.display()))?;

    let config: Config = serde_json::from_str(&text)
        .context("config file is not valid JSON")?;

    Ok(config)
}

When the error surfaces (typically in main), printing it through anyhow's Debug impl walks the chain and prints each layer:

Error: failed to read config at /etc/myapp/config.json

Caused by:
    No such file or directory (os error 2)

This is the practical replacement for the named-variant precision that thiserror provides. Instead of the caller branching on MyError::ConfigMissing, the error message itself records the path, the operation, and the cause.

Two flavours of context exist: .context("static string") for messages with no runtime data, and .with_context(|| format!(...)) for messages that need to capture a value such as a path, an id, or a count. The closure-based form is lazy, so the format work only happens when the error is actually being constructed.

Library or application?

The split between thiserror and anyhow tracks the split between a library and an application. Library code uses thiserror (or hand-written enums). The error type is part of the public API; callers may want to handle specific failure modes differently, log them differently, retry only some of them, and erasing variants takes that ability away. Application code uses anyhow. The application is the top of the call stack, not a library, and the only sensible response to most errors is "log and exit". Variant-level precision adds maintenance cost without unlocking behaviour the binary would actually exercise.

Mixed projects are common. A workspace with several library crates and one binary crate typically has each library expose a thiserror-derived error type, and the binary uses anyhow::Result at its top level. The conversion happens at the boundary via ?, which works because any Error + Send + Sync + 'static value implements Into<anyhow::Error>:

// in the library crate:
#[derive(Debug, thiserror::Error)]
pub enum DbError {
    #[error("connection failed")]
    Connect(#[from] std::io::Error),
    #[error("query failed: {0}")]
    Query(String),
}

// in the binary crate:
use anyhow::{Context, Result};

fn run() -> Result<()> {
    let conn = db::connect("postgres://...")
        .context("opening database connection")?;       // DbError → anyhow::Error
    let users = db::all_users(&conn)
        .context("loading initial user list")?;         // DbError → anyhow::Error
    println!("{} users", users.len());
    Ok(())
}

The Swift parallel doesn't split this cleanly. Swift's throws doesn't declare an error type at all in 2020-era Swift, so library and application code share the same calling convention; there's no boundary at which the spelling changes. Rust's split exists precisely because typed errors and erased errors are spelled differently in the type system, which gives library authors a reason to design an error type and application authors a reason to skip that work.

Panics, unwrap, and the unrecoverable side

Result and ? cover the recoverable path. The third pillar is panic!, Rust's mechanism for failures the program isn't expected to recover from: an invariant the code itself was supposed to maintain has been violated, and continuing would produce nonsense.

panic!("buffer length {} cannot exceed capacity {}", len, cap);

A panic unwinds the stack by default, running every Drop impl on the way up, then aborts the current thread. If the thread is the main thread, the process exits non-zero. Release builds can be configured to skip unwinding and abort directly via panic = "abort" in Cargo.toml, the standard choice for binaries that have no Drop cleanup to depend on.

The Swift analogues are fatalError and precondition. The role is the same: assertions about state the programmer guarantees, intended to crash the program when the guarantee is violated.

unwrap and expect

unwrap and expect are convenience methods on Option and Result that return the inner value, panicking if the value is None or Err:

let v: Option<i32> = Some(42);
let x = v.unwrap();                                   // 42

let r: Result<i32, _> = "42".parse();
let n = r.expect("hard-coded literal must parse");    // 42

Both panic on the failure path. The difference is only the message: unwrap produces a generic one (called Option::unwrap() on a None value), expect uses the string you provide. Treat the expect message as the documentation for why the unwrap is sound. Reading the message after a crash should immediately tell the maintainer which invariant broke.

unwrap is almost always wrong in production code

In practice, every failure a programmer reaches for unwrap to handle is recoverable, or ought to be. A missing config file is recoverable (log, exit non-zero with a useful message, or fall back to defaults). A network timeout is recoverable (retry, or surface to the caller). A failed parse is recoverable, because the input came from somewhere and that somewhere needs to know it produced bad data. The answer for all of these is ?, returning the error to a caller that has more context than this function does.

The same logic applies to the cases that look like programmer bugs at first glance. A function that slices &str at an index that turns out not to be a UTF-8 boundary, a HashMap lookup that misses a key the code expected to be present, a Mutex::lock() that returns a poisoning error: each of these is technically a violated precondition, but in any non-trivial program each can also be contained. A web server that panics on any of them takes the whole process down and aborts every other in-flight request as collateral. The same bug, surfaced as Err, fails one request, returns 500 to that caller, logs, and lets the rest of the workload continue. The design question is the blast radius, not whether the failure technically counts as a bug.

The genuinely valid cases for panic! are narrow. They share one property: the corruption can't be constrained to a smaller failure unit. Heap-allocator internal inconsistency, where unwinding through corrupted state is itself unsafe. Boot-phase invariants in fn main() before any worker, request handler, or supervisor exists to propagate to. debug_assert! and unreachable! markers documenting properties that a fuzzer or test suite should hunt for. Outside cases like these, surface the failure as an error and let the caller (request handler, job worker, supervisor) decide the scope.

When expect does belong, treat the message as the proof: a sentence explaining why the value cannot be None/Err here. "We just inserted this key two lines above" or "static initialiser, runs once at boot, no caller exists" is the kind of justification a Rust reviewer expects to read.

Tests are not an excuse

The historical idiom in Rust tests was to write .unwrap() everywhere, on the grounds that a test that panics is just a test that fails. Modern Rust (since 1.36, July 2019) lets test functions return Result, which means ? works in tests too:

#[test]
fn parses_a_valid_config() -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string("tests/fixtures/valid.toml")?;
    let config: Config = toml::from_str(&text)?;
    assert_eq!(config.port, 8080);
    Ok(())
}

A test that returns Err(...) fails with the error displayed in the test output, which is meaningfully better debugging than a unwrap panic that prints Err(IoError(...)) with no operation context. The same applies to fn main() -> Result<(), Box<dyn std::error::Error>>, stable since 1.26 (May 2018): a binary's entry point can propagate errors out instead of unwrapping them at the top level.

The practical rule. In library code, never unwrap or expect for any condition the caller could reasonably encounter at runtime. Return Result instead. In application code, prefer ? plus anyhow::Context over unwrap; the error path through main is the same, but the failure message is dramatically more useful. In tests, return Result from the test function and use ?; reserve unwrap for cases where the value is genuinely a hardcoded constant inside the test body. In expect calls where it does belong, write a sentence explaining the invariant. The message is the documentation.

unwrap is not a forbidden function. It's a function whose every use should be defensible in code review.

The next post is memory and ownership, where Swift's runtime mechanisms (Automatic Reference Counting, weak/unowned references, copy-on-write) get reorganised into compile-time rules. The series keeps its side-by-side format, and even on the topic where Rust diverges most clearly from Swift, every Rust mechanism is framed against the Swift mechanism that solves the same underlying problem.