Rust: Fundamentals

Keywords

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:

  1. 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 either Some(value) or None. The absence is visible in the type, so a caller cannot pretend it isn’t there.
  2. There are no exceptions. A operation that can fail returns a Result, which is either Ok(value) or Err(error). Failure is a value you receive and must do something with — not invisible control flow that unwinds the stack behind your back.
  3. enum plus exhaustive match makes forgetting a case a compile error. Option and Result are just enums — sum types, “this is exactly one of these variants” — and when you branch on an enum with match, 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 cargo and the toolchain fit
  • How enum sum types plus exhaustive match make the compiler reject code that forgets a case
  • How Option models absence safely, and how its combinators replace null checks
  • How Result represents 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 (rustup installs rustc, cargo, rustfmt, and clippy), 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 immutable

The 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.

War story: the .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 Result and ? 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 as enum error types matched exhaustively across the stack.


Practical exercise

Difficulty: Level I · Level II · Level III

  1. Level I — Model a domain with an enum, handle it with an exhaustive match. Define an enum for 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 a match with 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.

  2. 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 returns Option or Result, 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.

  3. 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 enum with one variant per distinct failure mode (e.g. NotFound, Malformed, OutOfRange), each carrying enough data to be actionable. Have the public functions return Result of 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 is Result (Ok/Err). Both are values the compiler forces you to handle.
  • Exhaustive match is 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 != nil after every call rather than a ? that propagates for you, and no compiler-enforced exhaustiveness. That chapter sets Rust’s Result + ? beside Go’s if 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::optional provide 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 the std::error::Error trait reference, plus ecosystem crates (thiserror for library error enums, anyhow for 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 — Option and Result are Rust’s descendants of ML’s option and Haskell’s Maybe and Either, 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 Option was designed to eliminate.