The hole C left: a value that might not be there
You have spent this rung watching Rust replace C's quiet dangers with compile-time rules — ownership decides who frees a value, and the borrow checker decides who may read or write it. This guide takes on the danger you have felt since the very first memory rung: the null pointer. In C, a `char *s` can point at a real string, or it can be NULL, and the type is identical either way. The compiler cannot tell the two apart, so it is on you to remember the check — and the cost of forgetting is the null-pointer dereference that turns into a segmentation fault if you are lucky and silent corruption if you are not.
The deep problem is that C has no way to say, in the type itself, "this might be absent." A function that looks up a key in a table must return something even when the key is missing, so C programmers reach for an in-band signal — a NULL pointer, a -1 index, a magic value — a sentinel smuggled into the normal range of the type. It works, but it is invisible to the compiler and easy to skip. Rust's answer is to stop smuggling. It gives you a real, separate type whose whole job is to say "present, or absent": the Option type, written `Option<T>`, with exactly two shapes — `Some(value)` when there is a `T`, and `None` when there is not.
The crucial move is that `Option<T>` and `T` are different types. A function that might find nothing returns `Option<i32>`, never a bare `i32`. So when you call it you do not get back an integer you can use directly — you get back a box that is either `Some(42)` or `None`, and the only way to reach the 42 inside is to first deal with the possibility of `None`. The empty case is no longer a footnote you might forget; it is a wall the compiler makes you walk through. That is the single idea behind "no nulls": absence is moved out of the value and into the type, where it cannot hide.
Opening the box with match
If the value you want is locked inside `Some(...)`, how do you get it out? The honest, foundational way is pattern matching with the `match` keyword. A `match` lists every shape the value could take and gives each one its own branch, and — this is the part that makes it safe — the Rust compiler checks that you have covered every shape. For an `Option<T>` there are exactly two: `Some(x)` and `None`. Leave out the `None` arm and your program will not compile. The compiler is, in effect, the patient reviewer who refuses to let you ship code that forgot the empty case.
Picture a table lookup. In Rust, `table.get(&id)` hands you an `Option<&String>`, and a `match` on it has two arms: `Some(name) => println!("hello, {name}")` and `None => println!("no user {id}")`. Delete the `None` arm and the build fails. Set the C version beside it — `char *name = table_get(table, id);` followed by `if (name) ... else ...` — and the difference is stark: that C compiles cleanly even if you simply forget to write the `else`, walking straight into the NULL with no warning at all. Same logic, but only one of the two languages makes the empty arm mandatory.
Two things are worth noticing in that picture. First, inside `Some(name)` the value is bound to the name `name`, and it only exists in that branch — you literally cannot use `name` where it might be absent, because that code path is the `None` arm where there is no name. Second, this is the same match machinery you would use to take apart any Rust enum, not a special gadget bolted onto Option. `Option<T>` is just an ordinary two-variant enum from the standard library; it feels built-in only because it is used everywhere. Understanding it as a plain enum demystifies it: there is no magic, just a tagged union the compiler insists you destructure completely.
Result: failure becomes an ordinary value too
Absence is one half of the story; failure is the other. Recall the C error model from the robustness rung: a call signals failure through a sentinel return value — `-1` from open(), NULL from malloc() — and the reason lives separately in the global errno, which you must read at exactly the right instant before the next call clobbers it. That split between "did it fail" and "why" is fragile precisely because the two parts can drift apart. Rust folds them back together with the Result type, written `Result<T, E>`, with two shapes: `Ok(value)` carrying the successful `T`, and `Err(error)` carrying an error value `E` that describes what went wrong, all in one returned thing.
Notice how this directly heals the errno fragility. There is no separate global to read at the right moment, no window where the reason can be overwritten — the reason rides inside the same `Err(...)` that told you it failed, bound together and impossible to mismatch. And just like Option, `Result<T, E>` is a different type from `T`, so a function that can fail returns the Result, and you cannot use the success value without first confronting the `Err` arm. The whole "check the return value, then maybe check errno, in the right order, before the next call" dance collapses into one honest question the compiler makes you answer: was it `Ok` or `Err`?
Two further touches make this practical rather than preachy. The standard library marks Result with an attribute that means "do not silently discard me" — written in words, a must-use marker — so if you call a function that returns a Result and ignore the value entirely, the compiler emits a warning. The thing C could never do, nag you for dropping an error on the floor, Rust does by default. And `E`, the error type, is yours to choose: a simple enum of your own failure cases, or a richer type carrying a message and a cause. The error is data you design, not a single overloaded integer.
Propagating errors: the ? operator and the C parallel
In the robustness rung you met the choice every error forces: handle it here, propagate it up, or abort. Propagation in C is a manual chore — a function detects `-1`, does its own cleanup, and returns `-1` in turn, so the failure climbs the call stack by hand at every level. Rust keeps the exact same idea but gives it one tiny piece of syntax: the question-mark operator, written `?`. Place it after an expression that yields a Result, and it means "if this is `Ok`, unwrap the value and carry on; if it is `Err`, return that `Err` from the whole function right now."
// Rust: ? propagates the Err automatically; on Ok it hands back the value
fn read_config(path: &str) -> Result<String, std::io::Error> {
let text = std::fs::read_to_string(path)?; // Err here -> return it now
Ok(text) // reached only if read succeeded
}
// the equivalent honest C, written out by hand:
// int fd = open(path, O_RDONLY);
// if (fd == -1) return -1; // propagate
// ssize_t n = read(fd, buf, len);
// if (n == -1) { close(fd); return -1; } // propagate, after cleanupSeeing the C beside it shows that `?` is not new magic — it is the propagation discipline you already practiced, lifted into the language so you cannot get the boilerplate subtly wrong. And it interlocks with what you learned about ownership earlier in this rung: when `?` returns early on an `Err`, every value the function owned up to that point is still dropped on the way out, automatically, by the same drop-on-scope-exit rule that frees memory and closes files. The leaked file descriptor that haunted the C propagation path — the very problem the goto-cleanup guide had to solve — simply cannot happen here, because cleanup is owned by the values, not by the control flow.
Two different jobs: recoverable errors vs bugs
It matters that Option and Result are for expected situations, not for programming mistakes — and Rust draws this line where the robustness rung drew it, between a recoverable error and a bug. A file that is missing, a number that fails to parse, a key absent from a map: these are ordinary, anticipated outcomes, and they belong in `Option` and `Result`, handled as values. A bug — an index past the end of an array, an invariant your own code was supposed to maintain but broke — is a different animal: it means the program is now in a state its author did not intend, and continuing is unsafe.
Rust handles that second category with a panic, the immediate controlled stop that `unwrap()` triggers and that an out-of-bounds index triggers on its own. A panic is closer to a C abort()-with-a-message or a failed assertion than to a recoverable error: it is the language saying "the assumptions are already broken; do not pretend you can continue." The discipline, then, is a judgment call you make per situation — return a `Result` for what the caller can reasonably be expected to handle, and let a panic fire for what should have been impossible. Using `unwrap()` everywhere to dodge the empty case throws away the whole benefit; it is exactly the silent ignoring that C let you do, dressed in Rust syntax.