A program that never throws
This chapter opens the way the last one did: with a complete, working program, before any rule. It reads lines of the form host port, builds a usable address out of the good ones, and prints a reason for each bad one. It does all of this without a single exception and without ever crashing. Read it top to bottom once for shape, then press Run:
// Reads "host port" lines and reports which endpoints are usable.
// Nothing here throws: every failure is returned as a value and handled.
fn parse_port(text: &str) -> Result<u16, String> {
text.trim()
.parse::<u16>()
.map_err(|_| format!("{text:?} is not a port"))
}
fn endpoint(line: &str) -> Result<String, String> {
let (host, port) = line
.split_once(' ')
.ok_or_else(|| format!("{line:?} has no port"))?;
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
let lines = ["api 8080", "cache 6379", "broken", "db notaport"];
for line in lines {
match endpoint(line) {
Ok(addr) => println!("ready {addr}"),
Err(why) => println!("skip {why}"),
}
}
}It prints a line per input: ready api:8080, ready cache:6379, then skip with a reason for broken (no port) and for db notaport (the port is not a number). The two malformed lines did not throw, did not abort the loop, and did not take the program down with them. Each failure came back as an ordinary value, and the loop handled it like any other result.
A few pieces run ahead of this chapter on purpose. The split_once that cuts a line into a host and a port, the ok_or_else that turns a missing split into a failure with a message, the ? riding on the end of a line, and the map_err that rewrites a parse failure into a sentence you can read. Do not try to decode them yet; each gets its own name in the sections that follow.
Here is the one idea to carry out of this program. In Rust, failure is a value you return and handle, never something thrown that travels invisibly up the stack. There is no hidden control flow looking for a catch somewhere above you. A function that can fail says so in its return type, and the caller can see, right at the call site, that an outcome has two shapes and both must be dealt with.
Not to make you write robust error handling yet, but to make you recognize its shape on sight: Option for absence, Result for failure with a reason, ? for propagation, and a match at the edge that handles both outcomes. The next sections name them one at a time, each with a small runnable program.
Errors are values, and you already believe this
If you write Go, the previous section was not a new idea dressed up in new syntax. You already return (value, err) and you already write if err != nil before you trust the value. You decided long ago that a failure is just another thing a function can hand back, not a special event that interrupts the world. Rust does not ask you to adopt a new philosophy here. It enforces the one you already use.
So read the program from section 01 again with Go eyes. Every place it returned an Option or a Result is the (value, err) idiom, with one change: the wrapper is a real type, and the language will not let you reach inside it until you have said what happens when the value is not there. The shape is familiar. What is new is who keeps you honest.
That is the whole of the difference, and it is worth saying plainly. In Go, the check is a discipline: nothing forces you to write if err != nil, and a tired afternoon can ship a function that ignored an error and read a half-built value. In Rust the check is a type: a Result is not a number or a string, it is a box that might hold either, and you cannot use it as the success value until you have opened the box and accounted for the failing side. The same instinct, moved from your memory into the compiler.
It helps to name the alternative both languages reject. In the exception-culture languages, a function can fail without a word in its signature: the return type promises an int, and at runtime a throw can travel up through callers that never mentioned failure, past every line that looked safe, until something far away catches it or nothing does. The failure is invisible at the call site. You cannot tell, from looking at parse(text), whether it quietly returns or violently leaves.
Rust and Go both refuse that bargain. A function that can fail says so in its return type, and the failure comes back to you as an ordinary value you can see, name, and pass around. Where they differ is only that Go trusts you to check it and Rust makes the check the price of admission.
Languages with exceptions let a failure leap out of a function that never mentioned it could fail. Rust has no try, no catch, no throw for ordinary errors. If a function can fail, its return type says so, and the failure is a value sitting in your hand, not a missile flying past you.
Option: absence with no null
When a value might not be there, Rust does not hand you a pointer that could secretly be empty. It hands you an Option<T>: a type with exactly two shapes. Some(value) means the value is present; None means it is absent. Both are variants of the same one type, so a function that might come up empty says so right in its signature, and every caller can see it. Here is a lookup that sometimes finds nothing. Run it:
// A lookup that might not find anything returns Option, not a null pointer.
fn find_user(id: u32) -> Option<&'static str> {
if id == 42 {
Some("Alice")
} else {
None
}
}
fn main() {
// The compiler will not let you use the result as a name until you
// have said what happens when it is absent.
match find_user(42) {
Some(name) => println!("found {name}"),
None => println!("no user with that id"),
}
// unwrap_or supplies a fallback for the None case in one step.
let name = find_user(7).unwrap_or("nobody");
println!("id 7 resolves to {name}");
}The thing to notice is what you cannot do. An Option<&str> is not a &str, and the compiler will not let you treat it as one. There is no name to print until you have said what happens when it is absent. The honest way to say it is match, the same construct from I.01: one arm for Some(name), one arm for None, and the absent case handled in plain sight.
When all you want for the missing case is a fallback, unwrap_or does it in one step: find_user(7).unwrap_or("nobody") gives back the name if it is there and the fallback if it is not. It is the same handling, folded into a single call.
In Go, nil is the zero value of a pointer: the same type as a real one, so nothing forces a check. The check is a discipline you remember, or do not. In Rust, None is a value of type Option<T>, carrying the knowledge that something was optional. Absence is tracked by the type, so it cannot be silently dereferenced.
You will also see Option chained with combinators like map all over real Rust, transforming the value inside without ever unpacking the None case by hand. They are worth recognizing now; why they compose so cleanly is the story of the chapter on monads later in this book. For Part I it is enough to know that handling absence rarely means a wall of match.
Leave out the None case in a match on an Option and the code does not compile (error E0004, non-exhaustive patterns). The same exhaustiveness you met in I.01 is what makes the absence impossible to forget here: the compiler counts the variants, sees one is unhandled, and stops.
Result: absence with a reason
Option can say only one thing about a missing value: that it is missing. None carries no story. Often that is enough, but sometimes you need to know why the value is not there. Result is Option with that detail added: Ok(value) for success, and Err(reason) for failure that hands back something describing what went wrong. Where None says nothing is here, Err says here is what went wrong.
The reason can be anything you choose. A quick-and-dirty parser might return Result<u16, String> and put a human sentence in the Err. Library code often returns a standard-library error type. When the failures are few and known, the cleanest choice is a small enum that names them, one variant per way the thing can fail. That is what the port parser below does: it returns Result<u16, PortError>, and PortError has exactly two variants, Empty and NotANumber. Run it:
// Result is like Option, but the failure carries a reason.
#[derive(Debug)]
enum PortError {
Empty,
NotANumber,
}
fn parse_port(input: &str) -> Result<u16, PortError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(PortError::Empty);
}
match trimmed.parse::<u16>() {
Ok(port) => Ok(port),
Err(_) => Err(PortError::NotANumber),
}
}
fn main() {
for input in ["8080", " ", "https"] {
match parse_port(input) {
Ok(port) => println!("{input:?} -> port {port}"),
Err(e) => println!("{input:?} -> error {e:?}"),
}
}
}Three inputs, three outcomes. "8080" parses, so the match takes the Ok arm and prints the port. The blank string and "https" each take the Err arm, but they do not fail the same way: one comes back Err(PortError::Empty) and the other Err(PortError::NotANumber). The caller can tell them apart because the failure is a value with a name, not a single anonymous absence.
Notice where that name lives. PortErrorappears right in the function’s return type, Result<u16, PortError>. The signature is a promise about how the call can go wrong, so the caller knows the shape of failure in advance, before ever running the code. There is no hidden set of things that might be thrown from deep inside; the kinds of failure are written down where you can read them.
The mechanics of building richer error types, teaching them to convert into one another, and giving them the standard Error behaviour, are a Part III story. For now the shape is all you need: a success variant, a failure variant that carries a reason, and an error type the author picks to fit the job.
Go splits success and failure across two return values and a nil convention. Rust folds them into one enum with two variants, so the compiler can enforce that exactly one is present and that you handle both. A Result is always one variant or the other: there is no niland no both-set state for a caller to slip through, the way Go’s two return values allow.
Call a function that returns a Result and throw the value away, and Rust warns you: the type is marked must_use. The language nudges you toward handling failure even when you try to drop it on the floor, a stronger push than Go’s silent permission to ignore the second return value.
The ? operator: propagation without the pyramid
Once failure is a value, the obvious next problem is chaining. Real work is rarely one fallible step; it is a sequence of them, each of which can fail, and each later step needing the result of the one before. If you handle every failure with a full match, the steps stop reading top to bottom and start nesting: one match inside the Ok arm of the last, then another inside that. Three or four steps in and your code drifts steadily to the right, a shape known as the pyramid of doom. Every line of real logic is buried a little deeper, and the error arms repeat the same say-nothing, return-the-error refrain.
Rust's answer is a single character. Put a ? after any expression that yields a Result, and it means: if this is an Err, stop and return it now; otherwise unwrap the Ok and carry on. The failure path leaves the function quietly, and the value you actually wanted stays on the main line, ready for the next step. The pyramid flattens back into a straight column of statements.
Here is the same kind of chain you would write by hand, but each fallible call is one line with a ? on the end. Notice that load reads like plain imperative code, get the first word, parse it, return it, with no error handling in sight, and yet every failure is fully accounted for. Notice too that main returns Result, which is what lets a ? work at the top level. Run it:
// ? is the one-character version of Go's `if err != nil { return err }`.
#[derive(Debug)]
enum ConfigError {
Empty,
NotANumber,
}
fn first_word(line: &str) -> Result<&str, ConfigError> {
line.split_whitespace().next().ok_or(ConfigError::Empty)
}
fn parse_port(input: &str) -> Result<u16, ConfigError> {
input.parse::<u16>().map_err(|_| ConfigError::NotANumber)
}
fn load(line: &str) -> Result<u16, ConfigError> {
let word = first_word(line)?; // Err? return it now. Ok? unwrap it.
let port = parse_port(word)?; // same again, on a straight line
Ok(port)
}
fn main() -> Result<(), ConfigError> {
let port = load(" 9090 extra")?;
println!("listening on {port}");
Ok(())
}If you have written Go, you already know what ? stands for. It is the one-character version of the four lines you type after almost every call: if err != nil { return err }. The difference is not the behavior, which is identical, but the noise: in Go the error check is as visually loud as the work, so the happy path and the failure path compete for your eye on every line. With ? the happy path stays in the foreground and the failure path recedes to a single mark you learn to read past.
Read operation()? as: match operation() { Ok(v) => v, Err(e) => return Err(e) }. It is exactly the early-return-on-error you write by hand in Go, compressed to one character. The happy path stays on the main line; the error path leaves quietly.
The same operator works on Option, and it is the same shape: ? on a None returns None from the function immediately, and on a Some it hands you the inner value. One operator, one mental model, across both of the types from the last sections. Run this and watch the None case bow out before the format! ever runs:
// ? works on Option too: it bows out early with None.
fn config_dir(home: Option<&str>) -> Option<String> {
let home = home?; // None? return None now. Some? unwrap it.
Some(format!("{home}/.config"))
}
fn main() {
println!("{:?}", config_dir(Some("/users/ada")));
println!("{:?}", config_dir(None));
}One more thing happens in the load example that is worth naming but not yet unpacking. The ? can convert one error type into another on its way out, so a function can call helpers that fail in different ways and still return a single, tidy error type of its own. The machinery behind that conversion (a trait called From) is a Part III story, and the deeper reason ? composes so cleanly, that Result and Option share a common shape, is a Part IV one. For now it is enough that the conversion just works.
You can use ? in a function returning Result (or Option), because that is where the early-returned error can go. That is why this example has main return Result<(), ConfigError>. A function returning a plain value has nowhere to send the error, so ? will not compile there; use match or unwrap_or instead.
Reading fallibility off the signature
Everything so far has been building one skill, and this is it: you can read how a function might go wrong from its return type alone, without reading a line of its body and without trusting a comment. The wrapper on the return type is a promise about how a call can fail, and Rust makes that promise part of the signature so you cannot miss it. Here are three functions side by side. Run it:
// The return type is a promise about how a call can go wrong.
// No wrapper: this always produces an i32.
fn add(x: i32, y: i32) -> i32 {
x + y
}
// Option: this might not find a value.
fn find_port(name: &str) -> Option<u16> {
if name == "http" { Some(80) } else { None }
}
// Result: this might fail, and it will tell you why.
fn parse_count(text: &str) -> Result<u32, std::num::ParseIntError> {
text.parse::<u32>()
}
fn main() {
println!("add always works: {}", add(2, 3));
println!("find_port may be absent: {:?}", find_port("ssh"));
println!("parse_count may fail: {:?}", parse_count("oops"));
}Read the return types top to bottom. add returns a plain i32, which says it always succeeds: there is no wrapper, so there is no other way for it to come back. find_port returns Option<u16>, which warns that it might find nothing: you will get a port, or you will get None, and the type makes you say which case you are in before you can use the number. parse_count returns Result<u32, std::num::ParseIntError>, which warns that it might fail and names the failure: success carries a u32, and failure carries a ParseIntError that tells you what went wrong.
Now picture the same three functions in Python or JavaScript. The signature would be silent. add, find_port, and parse_count would all look alike from the outside, and any one of them could raise an exception you never anticipated, or quietly hand back None with nothing in the signature to warn you. You learn what can go wrong by reading the body, by reading the docs, or by being surprised in production. In Rust the answer is right there in the return type, the same for every caller, kept honest by the compiler.
This is the payoff of treating errors as values. Because failure is encoded in the type rather than thrown past it, fallibility is discoverable: you can see it at a glance. And because the compiler will not let you touch the inner value until you have dealt with the Option or Result wrapping it, handling that failure is mandatory: you cannot forget the case that the type already told you exists.
You never have to read the body or trust a comment to learn whether a Rust function can fail. -> i32 says it always succeeds, -> Option<u16> says it may find nothing, and -> Result<u32, E> says it may fail with an E. The contract lives in the signature, so call sites can defend against exactly the failures that exist, no more and no fewer.
unwrap, expect, and panic: the deliberate exits
Every escape from a value-based world needs an honest door, and Rust gives you two: unwrap and expect. Both say the same thing out loud, I am certain this succeeded. Called on a Some or an Ok, they hand you the inner value. Called on a None or an Err, they panic: the program stops, prints a message, and exits with a non-zero status. The difference between the two is the message. unwrap crashes with a generic note; expect crashes with the sentence you wrote, which is the one thing the next reader (and the crash log) will have to go on.
The program below runs on a present value, so nothing panics yet. Read it, press Run, and watch both lines print. Then do the experiment in the comment: change Some(8080) to None and run again. The unwrap line now panics first, and if you comment that one out the expect line panics with your exact message, then the program stops:
// unwrap and expect say: "I am certain this is here."
// If you are wrong, the program panics; there is no recover() to catch it.
fn main() {
let config: Option<u16> = Some(8080);
// unwrap: take the value, or panic with a generic message.
let port = config.unwrap();
println!("starting on port {port}");
// expect: same, but leave a note for whoever reads the crash.
let again = config.expect("port should be set during startup");
println!("still {again}");
// Try it: change Some(8080) to None above and run again to see
// expect panic with that exact message, then stop the program.
}A panic is not a failure you handle; it is a bug you have discovered. Think of it as the exit Rust takes when one of your own assumptions turns out to be false. That is why unwrap and expect read as claims rather than error handling: the author is telling you, on the record, that this case cannot happen. When the claim holds, you get the value with no ceremony. When it does not, you get a loud crash that points at the exact line, instead of a quiet wrong answer that surfaces three functions later.
Go pairs panic with recover, so a deferred function can catch the panic and let the program carry on. Rust has panic! but no recover for ordinary code: a panic unwinds and ends the thread, and if that thread is main, it ends the whole program. So reserve panic for bugs you have discovered, a broken invariant in your own code, and return Result for the failures you actually expect.
unwrap and expect are fine when success is genuinely guaranteed: a hardcoded value, a test, or an invariant you have already enforced a few lines up. In a server's hot path, where the value comes from the network or a config file, the same call is a loaded gun. Read an unwrap in real code as a claim by the author, and prefer expect: its message tells the next reader why the author was certain, which is exactly what you want printed when the certainty turns out to be wrong.
Read it again
Now scroll back to the very first program in section 01 and read it once more. Every piece has a name now. The two functions return Result, the type that says a call might fail and will tell you why. Inside endpoint, ok_or_else turns the absence of a port into an error with a reason, and the ? right after it propagates that failure up the moment it appears. The map_err in parse_port reshapes one kind of failure into the message this function speaks. And at the edge, in main, a match handles both outcomes by hand: Ok prints a ready endpoint, Err prints why one was skipped. You can read it now.
Rust error handling is Option for might-be-absent, Result for might-fail-with-a-reason, ? to propagate either up to a caller who can decide, and a match at the edge that handles both outcomes. No exceptions travel invisibly; every failure is a value the compiler made sure you saw.
Two threads run ahead of this chapter, and each gets its own home. Here every failure was a String; in real programs you give failures their own types and let ? convert between them automatically through a trait called From. That is the story of Part III, chapter 08. And the reason Option, Result, and ? feel like one idea wearing two coats is that they genuinely share one shape; the monads chapter is where that shape gets its name and its rules.
The whole chapter in six lines
- Absence and failure are values, not control flow that travels behind your back.
Optionis for a value that might be absent:SomeorNone, and nonullanywhere.Resultis for a call that might fail with a reason:OkorErr.- The return type is a promise about how a call can go wrong, written where every caller can see it.
?propagates anError aNoneup to the caller;unwrapandexpectinstead panic, with norecover()to catch them.- A
matchat the edge is where both outcomes get handled, because the compiler will not let you forget one.