Rust: Fundamentals
rust, result, option, error handling, enums, pattern matching, traits, cargo, no null, expressions
Introduction
At 2 a.m. a payments service fell over with a stack trace that ended in a line nobody had thought twice about: a lookup returned an empty value, the code reached straight for a field on it, and the runtime threw. In Java the message is NullPointerException; in a Node service the same bug reads TypeError: Cannot read properties of undefined; in Python it’s AttributeError: 'NoneType' object has no attribute. Different languages, identical failure — a value that might be absent was treated as if it were present, and the gap was found not by the compiler, not by code review, but by a customer at the worst possible hour. The function that returned the empty value was correct. The caller that ignored the possibility was correct-looking. The bug lived in the silence between them: nothing in the type said “this could be nothing,” so nothing forced anyone to check.
This is the single most common class of production crash in modern software, and it has a sibling: the exception that no one caught. A function deep in a library throws, the exception unwinds invisibly through frames that never mentioned it, and it surfaces as a 500 in a handler that had no idea the call could fail. Both bugs share a root cause. In most languages, absence and failure are not part of a value’s type. A reference can secretly be null; any call can secretly throw. The type system says “this returns a User” while the truth is “this returns a User, or null, or it explodes” — and the parts it leaves out are exactly the parts that page you.
Rust makes that whole category of 2 a.m. crash impossible to write. Not harder — impossible, caught at compile time. It does this by pushing absence and failure into the type system, so the compiler refuses to build code that ignores them. There is no null; a value that might be missing has type Option, and you cannot read through it without first handling the missing case. There are no exceptions; a call that might fail returns a Result, an ordinary value that carries either success or an error, and you cannot use the success without confronting the error. The compiler is no longer a spell-checker for syntax. It is a proof engine that rejects programs containing these bugs.
The Core Insight
The insight behind Rust is to turn the compiler into an ally that proves whole categories of bugs absent before the program ever runs. Most type systems describe the happy path and stay quiet about the rest. Rust’s describes the unhappy paths too, and makes describing them mandatory. Three decisions do most of the work:
- There is no null. The billion-dollar mistake — its inventor’s own phrase — is simply not in the language. A value that may be absent has the type
Option, which is eitherSome(value)orNone. The absence is visible in the type, so a caller cannot pretend it isn’t there. - There are no exceptions. A operation that can fail returns a
Result, which is eitherOk(value)orErr(error). Failure is a value you receive and must do something with — not invisible control flow that unwinds the stack behind your back. enumplus exhaustivematchmakes forgetting a case a compile error.OptionandResultare just enums — sum types, “this is exactly one of these variants” — and when you branch on an enum withmatch, the compiler checks that you handled every variant. Add a variant later and every match that forgot it stops compiling, pointing at the exact lines to fix.
Layer strong static types and an expression-oriented core on top, and you arrive at the promise that makes Rust feel different from anything that came before it: if it compiles, a large class of bugs is already ruled out. Null dereferences, uncaught exceptions, forgotten error cases, type confusion — none of them survive the build. The compile is slow and the borrow checker is strict, but what you buy with that patience is a program that cannot fail in the ways that wake people up at night.
A mental model
Picture every fallible or optional result as a sealed box. The box is opaque: you can hold it, pass it around, store it — but you cannot use what’s inside until you open it, and opening it means dealing with both of its possible contents. A Result box holds either the thing you wanted or an error explaining why you can’t have it; an Option box holds either a value or nothing at all. There is no way to reach in and grab the value while pretending the error or the emptiness didn’t happen. The compiler holds the box shut until you prove you’ve handled every way it could be opened.
There are exactly two sanctioned ways to open the box. The first is match, which forces you to write an arm for each possibility — the success and the failure, the present value and the absence — and won’t compile if you skip one. The second is the ? operator, which is the ergonomic shortcut for the most common case: “give me the value if the box holds one, and if it holds an error, stop here and hand that same box back up to my caller.” ? means unwrap or return the error up. It lets failures flow upward through your code like a returned value — because that is exactly what they are — without a single line of manual plumbing. Figure 25.1 traces both paths: a function hands back a sealed box, and either a match must account for every variant or ? propagates the failure up the call chain.
When Rust fits
Rust lives at the high-control end of the language spectrum — the territory of C and C++, where you manage memory directly, no garbage collector pauses your program, and you pay for nothing you don’t use — but it brings memory safety and the type-level guarantees above with it. That combination is the reason to choose it.
Reach for Rust when correctness and control both matter and you refuse to trade one for the other: systems software (operating systems, drivers, embedded and bare-metal targets) where a garbage collector is unwelcome or impossible; performance-critical infrastructure (databases, network proxies, game engines, instant-start CLI tools) where you want C-level speed without C-level footguns; WebAssembly, where Rust’s small, predictable binaries shine; and correctness-critical services where a null dereference or a swallowed error is an incident, not an inconvenience. If a 2 a.m. page is expensive, the compiler’s strictness is an investment, not a tax.
Reach for something else when that strictness isn’t worth it. For prototyping and scripting, Python’s REPL and dynamic typing iterate faster. For most business CRUD and web services, Go compiles in seconds, onboards new engineers in weeks, and its garbage collector is a feature, not a flaw. For machine-learning training, Python’s ecosystem is unmatched. Rust’s learning curve is real and front-loaded — the borrow checker will fight you for the first month — so spend it where the guarantees pay for themselves, not where “good enough” genuinely is.
What you’ll learn
- Why Rust has no null and no exceptions, and how moving absence and failure into the type system turns a class of runtime crashes into compile errors
- How strong static types, immutability by default, and expression orientation shape everyday Rust, and where
cargoand the toolchain fit - How
enumsum types plus exhaustivematchmake the compiler reject code that forgets a case - How
Optionmodels absence safely, and how its combinators replace null checks - How
Resultrepresents errors as values, and how the?operator propagates them up the call chain with almost no ceremony - How to design a small library’s error type and reason about how callers handle it
- Where traits and generics fit, and a first, deliberately brief, taste of ownership
Prerequisites
- Programming basics: variables, functions, loops, and conditionals in any language; comfort reading code and following control flow.
- A working Rust toolchain is helpful for trying the examples (
rustupinstallsrustc,cargo,rustfmt, andclippy), but the chapter is readable without one. - No prior Rust is assumed. This is the entry point to the Rust track; ownership and traits each get their own chapter, referenced where they come up.
Types and expressions: the substrate
Before the error model can do its work, two background properties have to be in place, because everything else leans on them. The first is that Rust is strongly and statically typed: every value has a type known at compile time, and the compiler will not silently convert between them or let a type be something other than what it claims. A String is never accidentally a number; an Option is never accidentally the value inside it. This is the bedrock the guarantees rest on — “absence is in the type” only means something if the type system is taken seriously, and Rust takes it more seriously than most. Type inference keeps this from being tedious: write let count = 0 and the compiler figures out i32 from how count is used, so strong typing rarely means verbose typing.
The second property is immutability by default. A plain let binding cannot be reassigned; you must opt into mutation with let mut. This inverts the default of most languages, where everything is mutable until you remember to lock it down, and the inversion is deliberate: most variables never should change after they’re set, and making that the easy path eliminates a quiet source of bugs where a value mutates somewhere you didn’t expect.
let limit = 100; // immutable: `limit = 200` later would not compile
let mut total = 0; // explicitly mutable: `total += 1` is fine
const MAX_RETRIES: u32 = 5; // compile-time constant, always immutableThe third property is subtler and pays off throughout the language: almost everything is an expression, meaning it evaluates to a value. An if is an expression, so you assign its result directly instead of declaring a variable and mutating it in each branch. A block { ... } is an expression whose value is its last line. Even a match produces a value. This is why a function’s last line, written without a trailing semicolon, is its return value — the semicolon is what turns an expression into a statement that yields nothing.
// `if` is an expression; the whole thing evaluates to one value.
let tier = if score >= 90 { "gold" } else { "silver" };
fn double(n: i32) -> i32 {
n * 2 // no semicolon: this expression IS the return value
}That last detail is a famous beginner trap — add a semicolon after n * 2 and the function suddenly returns () (the empty “unit” value) instead of an i32, and the compiler rejects it. The error is precise and the fix is one character, but the lesson is conceptual: in Rust, the presence or absence of a semicolon is a meaningful choice about whether you’re producing a value or discarding one.
All of this is mediated by cargo, Rust’s build tool and package manager in one, and a quiet reason the ecosystem is pleasant. cargo new scaffolds a project, cargo run builds and runs it, cargo test runs the tests, cargo build --release produces an optimized binary, and dependencies (called crates) are declared in a Cargo.toml manifest and fetched automatically. Two more belong in muscle memory: cargo fmt formats code to the community standard so style is never a debate, and cargo clippy is a linter that catches whole categories of mistakes and suggests idiomatic rewrites.
Enums and pattern matching: making the compiler count cases
The feature that makes Rust’s error handling possible isn’t Result or Option themselves — it’s the machinery underneath them. Rust’s enum is not the weak “named integer” enum of C. It is a true sum type: a value that is exactly one of several variants, and each variant can carry its own data of its own shape. This is how you say “a message is either a quit signal, or a move with x and y, or some text” as a single type:
enum Message {
Quit, // carries nothing
Move { x: i32, y: i32 }, // carries named fields
Write(String), // carries a String
}A Message is one of those three things and never two of them at once, and the type system knows it. To use a sum type you branch on which variant it actually is, and the tool for that is match — but match is not just a switch statement, it is an exhaustive one. The compiler checks that you have an arm for every variant, and if you forget one, the program does not compile. This is the whole game. The compiler is counting your cases for you.
fn describe(msg: &Message) -> String {
match msg {
Message::Quit => "quit".to_string(),
Message::Move { x, y } => format!("move to ({x}, {y})"),
Message::Write(text) => format!("write: {text}"),
} // remove any arm above and this stops compiling
}Why this matters in practice is maintenance. Suppose six months from now you add a Message::ChangeColor variant. In a language with non-exhaustive switches, the old code keeps compiling and silently falls through to a default — and you discover the unhandled case in production. In Rust, every match on Message that didn’t account for the new variant fails to compile, and the errors are a precise to-do list of every place that needs updating. The compiler turns “did I update all the call sites?” from a nervous manual audit into a guarantee. When you genuinely want a catch-all, the _ wildcard arm is there — but it’s an explicit choice, not a silent default.
For the common case where you care about exactly one variant and want to ignore the rest, if let is the concise form: if let Message::Write(text) = msg { ... } runs the body only when msg is a Write, binding text along the way.
Option: absence with no null
With sum types and exhaustive matching in hand, Option is almost anticlimactic — it’s just an enum that ships with the standard library:
enum Option<T> { // T is any type; this is a generic enum
Some(T), // present: holds a value
None, // absent: holds nothing
}That tiny definition is the entire replacement for null. When a value might be missing — a lookup that might not find anything, a parse that might yield nothing, an optional field — its type is Option of something, and the missing case is now part of the type signature. A function returning Option of an integer is announcing, in a way the compiler enforces, that it might come back empty. The caller cannot read the integer without first opening the box and dealing with None, because the integer isn’t sitting there to read — it’s wrapped, and unwrapping requires acknowledging the alternative.
// The signature itself tells the caller: this can come back empty.
fn first_even(numbers: &[i32]) -> Option<i32> {
for &n in numbers {
if n % 2 == 0 {
return Some(n);
}
}
None // searched everything, found nothing — and the type says so
}A caller handles both cases with the same match machinery as any other enum, or with if let, or — most idiomatically — with one of Option’s combinators, which transform the value-if-present without spelling out the branches. unwrap_or(0) yields the inner value or a default; map(|n| n * 2) doubles the value if there is one and stays None otherwise; and_then chains another Option-returning step. These read as a pipeline and never mention null, because there is no null to mention. The contrast with the opening story is exact: the language that crashed had a value that might be absent with nothing in the type to say so. Rust makes that exact situation a type you cannot misuse.
Result and the ? operator: errors as values
Result is the same idea pointed at failure instead of absence, and it is the center of gravity for the whole chapter. It, too, is just an enum:
enum Result<T, E> { // T = success type, E = error type
Ok(T), // success: holds the value you wanted
Err(E), // failure: holds an error describing why not
}Any operation that can fail returns Result of a success type and an error type: reading a file, parsing a number, making a network call. Failure is not a thrown exception that unwinds the stack invisibly — it is an Err value handed back to you, right there in the return, impossible to ignore because you can’t get at the success value without first getting past the error. The function’s signature is honest about what can go wrong, and the type system makes that honesty load-bearing.
The exhaustive match works here exactly as before — one arm for Ok, one for Err — and for one-off calls that’s perfectly idiomatic. But chaining several fallible operations by matching each one would bury your logic in a staircase of nested branches, because every step would need its own Ok/Err handling before the next could begin. This is where the ? operator earns its place as the most distinctive piece of everyday Rust. Put ? after any Result-returning expression and it does one of two things: if the value is Ok, it unwraps it and execution continues with the inner value; if it’s Err, it returns that error immediately from the enclosing function. “Unwrap, or return the error up.” Failures propagate upward on their own, and the happy path reads as a clean straight line.
use std::fs;
// Each `?` means: succeed and continue, or return this error to my caller.
fn load_port(path: &str) -> Result<u16, Box<dyn std::error::Error>> {
let text = fs::read_to_string(path)?; // file error? return it up.
let port: u16 = text.trim().parse()?; // parse error? return it up.
Ok(port) // both worked: hand back the value
}Read that function and notice what’s missing: there is no error-handling noise in the body, yet every failure is fully accounted for. The two ? operators carry every error up to whoever called load_port, and that caller — which also returns Result — must in turn handle it or propagate it again. Errors travel up the call chain as ordinary returned values, exactly as Figure 25.1 shows, until some level decides to actually deal with them. Nothing is ever swallowed, and nothing unwinds behind your back. ? only works inside a function that itself returns Result (or Option) — the compiler insisting, once more, that you can’t propagate a failure into a place with no way to receive it.
One honest caveat, which the war story below sharpens: Result also has escape hatches, .unwrap() and .expect(), that extract the success value and panic (crash the program) on an error. They exist for prototypes and for cases you can prove impossible. Reach for them in production and you have quietly rebuilt the very thing Rust set out to abolish — an unhandled failure that takes down the process — only now it’s spelled unwrap.
Traits and generics, and a first taste of ownership
Two more pillars round out the foundations, and both get full chapters of their own, so here we only place them. Generics are how Option<T> and Result<T, E> work for any type without being rewritten per type — the T is a placeholder filled in at compile time, so Option<i32> and Option<String> are both real, fully-checked types with no runtime cost. Traits are Rust’s answer to shared behavior: a trait names a set of methods a type can implement, much like an interface, and generic code can demand “any type that implements Display.” Together they are how Rust achieves zero-cost abstractions — generic and reusable in source, specialized and fast in the binary. The Traits chapter develops this properly.
Then there is ownership, Rust’s defining idea and the source of both its memory safety and its learning curve. The one-sentence version, enough to read the code here: every value has exactly one owner, and when the owner goes out of scope the value is freed — no garbage collector, no manual free, no double-free. Assigning or passing a value can move ownership (after which the old name is unusable), or you can lend temporary access by borrowing with &. This is why you’ll see & and &mut through Rust code, and why the compiler sometimes objects that a value “has been moved.” That whole system — moves, borrows, the borrow checker, lifetimes — is the subject of the very next chapter, Rust: Ownership & Borrowing. Here, just hold the headline: memory safety in Rust is something the compiler proves, not something a runtime cleans up after.
.unwrap() that put the crash back
A team migrated a request handler to Rust expecting the type system to end their null-crash problem, and for a while it did. Then, under deadline, the codebase filled up with .unwrap() — on a config lookup, on a parse, on a cache read — each one a tiny shortcut past the Err case the compiler was trying to make them handle. Every .unwrap() is a silent promise that “this can never fail,” and one of them was wrong: a config value the operators could leave unset. On the one deploy where they did, the handler hit std::env::var("...").unwrap(), panicked, and took down the request — the exact 2 a.m. failure mode Rust exists to prevent, faithfully recreated one unwrap at a time. The fix was mechanical and the lesson is permanent: .unwrap() and .expect() are for prototypes and provably-impossible cases. In real code, handle the error with match, supply a default with unwrap_or/unwrap_or_else, or propagate it with ?. The compiler will hand you a Result; the safety only holds if you don’t immediately throw it away. A codebase sprinkled with .unwrap() has opted out of the guarantee it adopted Rust to get.
Build it → See the error model carry real load. The Rust services in Project 03: High-Performance Cache and the Project 06: Async Runtime thread
Resultand?through hot paths where a panic is not an option, and the Project 11: Distributed KV / Raft and Project 17: Columnar Query Engine model their failure modes asenumerror types matched exhaustively across the stack.
Practical exercise
Difficulty: Level I · Level II · Level III
Level I — Model a domain with an enum, handle it with an exhaustive match. Define an
enumfor a small domain you know — say a traffic light (Red,Yellow,Green), an HTTP method, or a shape with per-variant data. Write a function that takes one and returns a description, using amatchwith an arm per variant and no_wildcard. Then add a new variant to the enum and observe that the function no longer compiles. Read the error, fix it, and write a sentence on why “it stopped compiling” is a feature here rather than an annoyance.Level II — Rewrite null-returning / exception-throwing logic with Option, Result, and
?. Take a small pipeline that, in a language you know, would return null or throw: read a string from somewhere, parse it to a number, look that number up in a collection, transform the result. Implement it in Rust so each fallible step returnsOptionorResult, and chain them with the?operator end to end. The constraint: no.unwrap()or.expect()on the happy path — every failure must propagate through?to a single handler at the top. Note how the body reads as a straight line despite handling every error.Level III — Design a library’s error type and defend it. Design a small library (a config loader, a tiny parser, a URL validator) and give it a dedicated error
enumwith one variant per distinct failure mode (e.g.NotFound,Malformed,OutOfRange), each carrying enough data to be actionable. Have the public functions returnResultof your success type and your error type. Then write a short piece of prose, as if for the crate’s docs, explaining: how a caller handles your errors (matching on your variants vs. propagating with?); how this differs from how an exception would propagate through their code in a language with exceptions; and why exhaustive matching on your error enum changes the maintenance story when you later add a new failure mode.
Summary
Rust’s defining move at the fundamentals level is to push absence and failure out of the shadows and into the type system, where the compiler can enforce that you deal with them. There is no null — a value that might be missing is an Option, either Some or None. There are no exceptions — a call that might fail returns a Result, either Ok or Err, an ordinary value you receive. Both are just enum sum types, and because match is exhaustive, the compiler rejects any code that forgets a case. The ? operator makes propagating failures up the call chain nearly free, so the happy path stays readable while every error is still accounted for. Wrap all of this in strong static types, immutability by default, and an expression-oriented core, and you get the proposition that defines the language: if it compiles, an entire class of the crashes that page people at 2 a.m. has already been ruled out — provided you don’t .unwrap() the guarantee away.
Key takeaways
- No null, no exceptions. Absence is
Option(Some/None); failure isResult(Ok/Err). Both are values the compiler forces you to handle. - Exhaustive
matchis the enforcer. Forget a variant and the program won’t compile; add a variant later and every match that ignored it lights up as a to-do list. That is maintenance safety, not just runtime safety. ?means “unwrap or return the error up.” It propagates failures through the call chain with no ceremony, keeping the happy path a straight line.- Expressions, immutability, strong static types are the substrate the guarantees rest on; the semicolon is a real decision about producing vs. discarding a value.
.unwrap()/.expect()are the escape hatch that rebuilds the crash. Use them for prototypes and provable impossibilities; in production, match, default, or propagate.
Connections to other chapters
- The Polyglot Landscape (context): this chapter places Rust at the high-control end of the spectrum — C/C++ territory, no garbage collector — but uniquely with memory safety and type-enforced error handling. That chapter’s map of where each language sits is the frame for why you’d pay Rust’s learning-curve cost at all.
- Rust: Ownership & Borrowing (next, extension): the very next chapter develops the idea we only previewed here — one owner per value, moves, borrows, and the borrow checker — which is Rust’s defining contribution and the other half of how it proves bugs absent at compile time.
- Error Handling (sibling, contrast): Go shares Rust’s errors-as-values philosophy but with very different ergonomics — an explicit
if err != nilafter every call rather than a?that propagates for you, and no compiler-enforced exhaustiveness. That chapter sets Rust’sResult+?beside Go’sif err != nil, exceptions, and the other languages’ approaches side by side — the cleanest way to see what?buys and what it costs. - C++: Fundamentals (contrast): C++ reaches similar ground from the other direction — exceptions and (since C++17)
std::optionalprovide absence and failure handling, but null pointers, uninitialized memory, and undefined behavior remain in the language. Contrasting the two shows what “the same performance class, but safe by construction” actually means.
Further reading
Essential
- The Rust Programming Language (“the Book”), Klabnik & Nichols — the canonical free introduction; its chapters on enums, pattern matching,
Option, and error handling cover this chapter’s core in depth. - Error Handling in the Rust documentation and the Rust API guidelines — the community’s conventions for
Result, custom error types, and when (not) to panic.
Deep dives
- Programming Rust, Blandy, Orendorff & Tindall — a rigorous treatment of the type system, traits, generics, and error handling for readers who want the full machinery behind the fundamentals sketched here.
- The
?operator and thestd::error::Errortrait reference, plus ecosystem crates (thiserrorfor library error enums,anyhowfor application-level errors) — the next step once you’re designing real error types.
Historical context
- The ML / Haskell lineage of algebraic sum types and exhaustive pattern matching —
OptionandResultare Rust’s descendants of ML’soptionand Haskell’sMaybeandEither, and exhaustiveness checking comes from the same tradition. - Tony Hoare’s “Null References: The Billion Dollar Mistake” — the talk by null’s own inventor that names the exact problem
Optionwas designed to eliminate.