Language Internals
Part 6 of 11 · Rust Language ProficiencyError Handling — Result, Option & ?
Result/Option, ?, thiserror/anyhow vs exceptions.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Result or Option?
Answer
Option is absence without a reason. Result is failure with an error value.
L2
What does the question-mark operator do?
Answer
On Err, return early (converted via From); on Ok, yield the value. It never panics. On None, return early the same way.
L3
Why not exceptions?
Answer
The failure is in the type. The same pattern works in async code (FFI boundaries still need status codes).
L4
When is panic appropriate?
Answer
A broken invariant, a bug, or an explicit todo during work in progress. Not for user input or I/O in a library.
L5
When is unwrap acceptable?
Answer
In a test or an example, or after a proof that the failure cannot happen. Prefer expect with the reason.
L6
thiserror or anyhow?
Answer
thiserror for a library's typed enum. anyhow for a binary that wants context and a single edge type.
L7
How do you turn Option into Result?
Answer
ok_or or ok_or_else attaches the error you want when the value is None.
Failure modes
unwrap on user input
A bad string panics the process instead of returning Err.
String errors in a library
Callers cannot match a variant and have to parse text.
anyhow inside a library
Downstream crates inherit a type they cannot exhaustively match.
Panic across FFI
An unwind leaving Rust into another language is undefined behavior unless it is caught at the boundary.
Misconceptions
The question mark is a try/catch.
It returns from the current function. It does not resume the caller after a handler block.
Option and Result are the same.
Option has no error payload. Result does.
Panic is how Rust reports errors.
Panic is for bugs. Recoverable failure is Result.
Interviewer traps
Wrapping every error as a string at the first function.
Keep a typed error in the library. Add context where the binary meets the user.
Using unwrap because the sample did.
Samples and tests may unwrap. A library API returns Result.
Design scenario
Same prompt for every reader.
Requirements
A typed StoreError in the library, question-mark propagation, and anyhow or similar only in main.
Traffic / scale
One lookup per request, sometimes a bad id.
Latency
Error construction must not hide the I/O.
Consistency
A missing row is not reported as a disk error.
Availability
A bad id returns Err. It does not abort the process.
Failure assumptions
- parse uses unwrap.
- main and the library share one string error.
Constraints
- Stay on Result and Option. Do not design a retry queue.
Prompt
A store loads a record by an id string. Missing rows and I/O failures must stay distinct. The binary logs context and exits.
Typed failure versus unwind
Prefer
Result in the signature
The caller handles the error or returns it. FFI and async see the same type.
- Option is absence.
- Libraries stay typed.
- Panic is a bug signal.
Alternative
Exceptions
TypeScript throw and Python raise unwind until something catches them.
- The signature can omit the failure.
- A wide catch hides bugs.
- Null and None are a second channel.
Overview
Recoverable failure is Result. Absence is Option. The ? operator returns early from the current function. Libraries expose a typed error. Binaries add context at the edge. Panic means the program broke an invariant.
Comparative
| Style | TypeScript | Python | Rust |
|---|---|---|---|
| Routine failure | throw | raise | Result |
| Missing | null or undefined | None | Option |
| Propagate | try/catch | try/except | question-mark operator |
| Use Result | Use panic |
|---|---|
| I/O, parse, user input | Bug or broken invariant |
| Library API | todo during work in progress |
Decisions
- 1
Step 1 Call a fallible fn that returns Result
- nextStep 2 Ok or Err
- ?
Step 2 Ok or Err
- OkStep 3a Use the value
- ErrStep 3b Can this layer handle it
- unwrap on a routine failureFailure path - panic unwinds the thread
- 3
Step 3a Use the value
- ?
Step 3b Can this layer handle it
- yesStep 4a match - retry, default, or map to a user message
- noStep 4b Propagate with the question-mark operator - From converts the error
- 5
Step 4a match - retry, default, or map to a user message
- 6
Step 4b Propagate with the question-mark operator - From converts the error
- nextStep 5 Library - typed enum via thiserror; app edge - anyhow plus context
- 7
Step 5 Library - typed enum via thiserror; app edge - anyhow plus context
- 8
Failure path - panic unwinds the thread
Lesson map
Error Handling — Result, Option & ?
Result/Option, ?, thiserror/anyhow vs exceptions.
Architecture. Step 1 Call a fallible fn that returns Result Ready. Step 2 Ok or Err Ready. Step 3a Use the value Ready. Step 3b Can this layer handle it Ready. Step 4a match - retry, default, or map to a user message Ready. Step 4b Propagate with the question-mark operator - From converts the error Ready. Step 5 Library - typed enum via thiserror; app edge - anyhow plus context Ready. Failure path - panic unwinds the thread Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Call a fallible fn that returns Result Ready"] B["Step 2 Ok or Err Ready"] C["Step 3a Use the value Ready"] D["Step 3b Can this layer handle it Ready"] E["Step 4a match - retry, default, or map to a user message Ready"] F["Step 4b Propagate with the question-mark operator - From converts the error Ready"] G["Step 5 Library - typed enum via thiserror app edge - anyhow plus context Ready"] X["Failure path - panic unwinds the thread Ready"] A -->|continues| B B -->|Ok| C B -->|Err| D D -->|yes| E D -->|no| F F -->|continues| G B -->|unwrap on a routine failure| X
Press Run. Snippets must be self-contained — no network, files, or native modules.
Rosetta — fallible parse
fn parse_id(s: &str) -> Result<u64, std::num::ParseIntError> {
s.parse()
}
fn load(s: &str) -> Result<u64, std::num::ParseIntError> {
let id = parse_id(s)?;
Ok(id)
}function parseId(s: string): number {
const n = Number.parseInt(s, 10);
if (Number.isNaN(n)) throw new Error(`bad id: ${s}`);
return n;
}
function load(s: string): number {
return parseId(s);
}def parse_id(s: str) -> int:
return int(s)
def load(s: str) -> int:
return parse_id(s)? is typed propagation without a try block at every layer. TypeScript and Python unwind until a catch. Callers can forget the handler because the signature looks total.
Rosetta — Option map
fn domain(email: &str) -> Option<&str> {
email.split_once('@').map(|(_, d)| d)
}function domain(email: string): string | undefined {
const i = email.indexOf("@");
return i >= 0 ? email.slice(i + 1) : undefined;
}
const upper = domain("a@b.co")?.toUpperCase();def domain(email: str) -> str | None:
parts = email.split("@", 1)
return parts[1] if len(parts) == 2 else Nonemap, and_then, and ok_or replace ad-hoc null tests. Optional chaining in TypeScript is similar ergonomics on a type that is still nullable.
Rosetta — library error versus binary context
A library error is a matchable enum. thiserror derives Display and From so ? converts sources. The sketch stays in comments because those crates are not the playground:
// enum StoreError {
// Io(std::io::Error),
// Missing(String),
// }A binary can return a context type and exit:
// fn main() -> anyhow::Result<()> {
// let data = std::fs::read_to_string("x.txt")?;
// Ok(())
// }async function main() {
try {
// await fs.readFile("x.txt", "utf8");
} catch (e) {
console.error(e);
process.exit(1);
}
}def main() -> None:
try:
open("x.txt").read()
except OSError as e:
raise SystemExit(e)TypeScript and Python often use one exception hierarchy from the library to the process edge. Rust splits the typed library error from the binary's context.
Interview Q&A
Why not exceptions?
Answer
Failure stays in the type. It works the same in async code (FFI boundaries still need status codes).
When is unwrap allowed?
Answer
Tests, examples, and the rare case you have proved the branch cannot fail. Prefer expect with that proof in the message. Libraries return Result.
What does the question-mark operator return?
Answer
On Err, return early (converted via From); on Ok, yield the value. It never panics. It is not a catch block.
thiserror or anyhow?
Answer
thiserror for a library enum callers can match. anyhow, or the same idea, at the binary edge where you want context and an exit.
Panic or Result for a bad port string?
Answer
Result. The user can fix the string. Panic is for a bug, such as an index you already checked.
How is Python raise different?
Answer
int throws ValueError and the caller might not declare it. Rust parse returns Result and the caller must handle or propagate.
How is TypeScript throw different?
Answer
The function type often says number. The throw is a side channel. Result would be the return type.
Which rustc is this pattern written for?
Answer
rustc 1.98.1 and edition 2024. The question-mark operator and From conversions are stable language, not a nightly lint.
Pitfalls
- unwrap on parse of user input.
- A single string error for every library failure.
- anyhow as the public error of a crate others depend on.
- catch-all thinking:
?does not resume after a handler. - Panicking across an FFI boundary.
Take a function that reads a file and parses an id. Name the library error variants and the single place you would add binary context.