Everyone who has written code for a few years has fixed the same bug: an object that was somehow “connected, but also in an error state.” A Connection class holding connected: bool next to error: String | null — four combinations, two of them meaningless, and the meaningless two are exactly the ones that page someone at 3 A.M. You added comments, wrote docs, caught it in review three times, and then the fourth newcomer in the fourth language wrote it wrong again.
Parts 1–3 were about how long things live; this part turns to what things are. Rust’s answer to “illegal states” is not a convention, not a linter, not better documentation — it’s making those states impossible to express in the type system. The three features that carry that answer also happen to be the three you’ll write most every day: enums that carry data, the exhaustiveness-checked match, and the Option/Result/? error-handling pipeline. This is where the type system starts paying interest.
Enums that carry data
If you come from Java, C#, or TypeScript, you think an enum is “named constants.” Rust’s enum is a different creature entirely — a sum type: each variant can carry data of a completely different shape:
enum Connection {
Disconnected,
Connecting { attempt: u32 },
Connected { session: Session },
Failed { reason: String },
}
Count the difference between the two models. The struct with a bool and a nullable field has four bit combinations, of which connected: true with error: Some(..) means nothing; this enum has exactly four states, each carrying precisely the data that belongs to it. Illegal states aren’t forbidden — they’re unrepresentable. The compiler deleted them for you.
The deeper property: data travels with the state. session exists only inside the Connected variant, so “calling session.send() without a session” is not a runtime error path — it doesn’t exist in the type. You can’t touch a field that isn’t there, just as you can’t borrow from a moved-from variable. Part 2’s rules guard memory safety; this one guards domain safety, and the mechanism is the same: move the invariant from a comment into a type, and let the compiler be the checker that never gets tired and never forgets.
What does this mean in real code? State machines — connections, orders, jobs, protocol parsers — go from “a diagram in a wiki page plus a screenful of defensive checks” to “one enum and one match.” That’s the title’s claim: in Rust, an enum is how you write a state machine.
match: exhaustive, or rejected
match looks like switch and behaves like something else entirely. The differences come in three layers:
match conn {
Connection::Disconnected => retry(),
Connection::Connecting { attempt } => wait(backoff(attempt)),
Connection::Connected { session } => session.send(msg),
Connection::Failed { reason } => log::warn!("gave up: {reason}"),
}
- The exhaustiveness check. Miss any variant and the build fails (E0004: non-exhaustive patterns). This rule pays off the day you add a variant: the compiler walks up to every match that must now answer the new case and points. Refactoring goes from “grep and pray” to “follow the red squiggles.” It is the most underrated productivity feature in the entire language.
- Binding is destructuring.
Connected { session } => ...pulls the data out of the variant and hands it to you, correctly typed. Patterns nest guards too:attempt if attempt >= 3 => ...— complex dispatch written once, read once. _is a knife. The wildcard arm saves you a few lines today and buys the compiler’s silence tomorrow — the matches that should have been flagged quietly fall into_. The convention is simple: for your own enums, spell out every variant; reserve_for external enums that will keep growing (the ones std marks#[non_exhaustive]).
When you only care about one variant, don’t stage a full match. if let handles “do this only on a hit”; let-else handles “bail out early otherwise” — the latter writes the guard clause into the syntax itself:
let Connection::Connected { session } = &conn else {
return Err("not connected".into());
};
session.send(msg) // below this line, session provably exists
Notice the pattern: checking and extracting are one operation. There’s no two-step dance of “check the state, then pray the field is non-null” — the compiler knows the exact shape of your data past the else.
Option: null’s funeral
In 2009 Tony Hoare apologized for inventing the null reference in 1965, calling it his “billion-dollar mistake” — a conservative estimate. Rust has no null. What it has is a perfectly ordinary enum in the standard library:
enum Option<T> {
Some(T),
None,
}
The point is not the rename; it’s that the type is different. An Option<String> is not a String — to use the value inside, you must first answer “what if it’s None?”, and the compiler watches you answer. Null’s problem was never that it crashes; it’s that it contaminates: one nullable return value pollutes every layer of the call chain until some layer forgets to check. Option writes “this might be absent” into the signature, and the contamination stops at the type boundary.
Daily use is a set of combinators, not an if pyramid:
.unwrap_or(default)— fall back to a default;.unwrap_or_else(|| compute())is the lazy version for when the default is expensive..map(f)— transform theSome, passNonethrough, keep chaining..and_then(f)— the version for whenfitself returns anOption, stringing fallible steps into one chain..ok_or(err)— the bridge intoResultland.
As for .unwrap(): in a prototype it’s speed; in production it’s a time bomb. Every unwrap is the assertion “this can never be None,” and history has not been kind to that class of assertion. The one legitimate reason to leave it in: you proved on the previous line, via types or logic, that it’s Some — so write that proof in a comment.
Result and ?: the type system’s first payoff
Result<T, E> is Option’s cousin: None becomes Err(E) carrying a reason. And the ? operator is where it all converges — it turns error handling from “a ceremony that interrupts the main line at every step” into “a pipeline with one checkpoint per line”:
fn load_config(path: &Path) -> Result<Config, AppError> {
let text = std::fs::read_to_string(path)?; // io error → converted, exits
let config: Config = toml::from_str(&text)?; // parse error → exits too
validate(&config)?; // domain error → still exits
Ok(config) // happy path is the last line
}
? does exactly two things: if the value is Ok(t), extract t and continue; if it’s Err(e), return from the current function immediately, calling From::from on the way out to convert the error into the type your signature promised. The happy path reads top to bottom in a straight line; error handling sinks into the signature and a single character. Compare the alternatives — three nested try/catch layers, or the if err != nil broken record — identical semantics, completely different shape, and shape determines whether you can still read your own code in three months.
Two ground rules that will serve you for a long time:
- Libraries use structured errors; applications use aggregate errors. Writing a library, define your own error enum so callers can distinguish your failures (the ecosystem’s
thiserrorcompresses the boilerplate to a few lines); writing an application, oneAppErrororanyhowcatches everything. The dividing line is a single question: does your caller need to react differently to your different errors? Yes → enum; no → aggregate. Ecosystem choices get their own dedicated part later. - Panics are bugs for the programmer;
Resultis failures from the world. Disk full, network down, malformed config — these are normal states of the world, and they travel inResult. Index out of bounds, an “impossible” invariant broken — those are bugs in the code; let them panic and die early in tests. Draw that boundary clearly and your program is honest.
One thoughtful detail: main can return a Result — fn main() -> Result<(), Box<dyn Error>> — so ? works all the way to the top of the program, and prototype code has the correct shape from line one.
Practice, then Part 5
- Model
Connectionas the enum and implementfn send(&self, msg: &str) -> Result<(), String>: onlyConnectedactually “sends” (aprintln!is fine); every other variant returns a descriptive error. Then try expressing the same constraint with a struct and a bool — count the invariants you must hold in your head, then count them in the enum version. - Add a
Closed { by_peer: bool }variant toConnectionand compile. Follow E0004 through every match — that’s the real feel of “the compiler refactors with you.” Now change one match to end in_and compile again: enjoy the silence, and remember its price. - Write
fn parse_port(s: &str) -> Result<u16, String>: reject empty strings, non-numbers, and values outside0..=65535. Constraint: no if/else pyramid — compose withmap,and_then, and?only. Then write three tests, one for each failure branch; error paths deserve the same testing budget as the happy path.
Part 5 completes “what things are” with “what things can do”: traits — Rust’s mechanism for shared behavior, how a world without inheritance organizes code, and the handful of standard traits you’ll implement constantly (Display, From, the Iterator family). From that part on, your code starts to look like Rust.