Ch.6: Rust Enums Make Bad States Impossible

Outline

Transcript

0:00 Welcome to Learning Podcasts. Rust: Enums and Pattern Matching. Today we replace null pointers and exceptions with 1 type-system idea, and the compiler refuses to let you get it wrong. Last chapter, structs gave you Rust's AND types. A user has a name and an email and an age. Every field exists at the same time, and the type cannot be partially populated. That is great for static records. But real backend data is also one thing or another thing, with different fields per case. A payment is pending, settled, or failed.

0:35 The fields you care about depend on which one. A job is queued, running, succeeded, or failed. An HTTP response is success, not found, or validation error. Structs cannot model that without going fuzzy on the optional fields. That is what enums are for. Each variant is a distinct case, and the variant decides what data is valid. Rust enums are vastly more powerful than the Python or Java keyword you may be remembering. Each variant in an enum can carry different data, and the data is what you would expect for that case.

1:07 Queued carries nothing. Running carries a started-at timestamp. Succeeded carries the output. Failed carries an error message and a retry count. Oh, interesting. So the type itself encodes which data is valid in which state. The fields are coupled to the variant, not floating around the object. Right. In Python you write a class with a status string and a pile of optional fields, and nothing stops a logically broken value from existing. A status of succeeded with a result of None. A status of failed with no error.

1:41 In Rust, the type makes that combination unrepresentable. If you want Succeeded, you provide the success data. If you want Failed, you provide the error. The bug class disappears. Once a value can be one of several variants, you need a way to ask which one you have and unpack the data. That is what match does. A match expression compares the value against a set of patterns, one arm per case. And the crucial property is that match is exhaustive. Your match must cover every variant. Add a new one, and the compiler points at every match that is incomplete.

2:17 In Python or Java, adding a new status creates a silent bug because one forgotten if chain still ships into production. In Rust, adding a new case creates a wave of compile errors that mechanically guide the refactor. The compiler stops feeling like a scold and starts feeling like backup. The refactor finishes when it stops complaining. Rust also gives you lighter tools when only one variant is interesting. If let is the concise form: do this only if the value matches this pattern. While let is the looping version.

2:51 So you use match when every case matters, and if let when one case is interesting and the rest are irrelevant. While let drains a container that returns Some until None, like popping a queue, polling a stream, or walking an iterator until it ends. Same mental model either way. You are destructuring a value whose shape is guaranteed by the type, and the compiler knows which fields exist in which case. The if let and while let forms are just match with 1 arm. The most common enum in Rust is Option.

3:23 Two variants: Some, which contains a value, and None, which contains nothing. A lookup that might fail returns Option of User. A vector index that might not exist gives you an Option. A config field that can be missing is modeled as Option of the inner type. Absence is in the type itself. This is what Java's Optional would feel like if the language committed to it, and it is much stricter than Python's None. Anything can become None in Python and you find out later when an attribute error explodes.

3:56 That is a debugging nightmare. In Rust you cannot accidentally call a method on None. If find user returns Option of User, you cannot just read the user's email. You match on it, you use map, or you bubble it out with question mark. Once Option clicks, Result is almost obvious. Two variants: Ok, which contains the success value, and Err, which contains the error. Same shape as Option, different domain entirely. So error handling in Rust is not a separate subsystem bolted onto the language. It is just enums and pattern matching applied to operations that can fail.

4:34 A parser returns either a parsed config or a parse error. A repository returns a user, a not-found, or a database error. A web handler translates those precise cases into different HTTP responses. So failure becomes a value, not a ghost in the call stack. A function that can fail says so in the type, and the caller has to handle it before reading the value. If every fallible call required a full match, Rust would be correct but exhausting. The question mark operator keeps the model usable. When you write a question mark after a Result, Rust does the boring part for you.

5:12 If the value is Ok, execution continues with the inner value. If it is Err, the function returns early with that error. The whole happy path stays a straight line. So you can write find user, then load order, then charge order, each ending in a question mark. The failure handling is implicit in the operator and visible at every line where it appears. Same shape as Python exceptions, but you have to ask for it. Exception propagation is implicit. Question-mark propagation is explicit. The same operator also works with Option.

5:45 Then there is panic, along with helpers like unwrap and expect. Panic is not Rust's ordinary error handling. It is the abort path, the "stop the program right now" tool. It is for bugs, broken invariants, and truly unrecoverable states. A missing config file the user supplied is not a panic. That is a Result. Network calls, file I/O, user input, database queries, those failures are part of normal reality. Use Result there. The line is, "is this a bug, or is this reality?" So unwrap on a network call is the wrong tool, even if it compiles.

6:27 Reach for it only when the value cannot be missing, like in tests or inside airtight invariants you control. In library and domain code, you usually want errors that mean something specific so callers can react to each one. That is where thiserror shines. You define your own error enum. with variants like config missing or invalid port, and thiserror generates the boilerplate that makes it behave like a proper Rust error type. Callers can match on your variants and react differently. At the application layer, especially in binaries and web services, you usually care more about adding context than preserving every matchable variant.

7:10 That is where anyhow is convenient. One flexible error container with good context chaining, so the logs tell you not just that a file read failed, but where and why. So thiserror in libraries, anyhow in applications. Two crates, one mental model. Pull all of that together and you get the phrase that explains why this chapter matters, the slogan that Rust people repeat. Invalid states unrepresentable. A payment cannot be both pending and settled. A job result cannot exist without the job. An HTTP response cannot be both success and not-found.

7:49 The mental category of "edge case I forgot to handle" gets much smaller, and so does the test surface for those edges. The compiler is doing the bookkeeping. Whole categories of bugs disappear because the type system refuses to represent them. More work at compile time, fewer ghosts in production. That deal is the whole appeal. Rust replaces null and exceptions with 1 idea: enums plus pattern matching. The compiler refuses to let you get it wrong, and once you see the shape, you start using it everywhere.

8:23 Next chapter we look at traits and generics, how Rust composes behavior without inheritance. Thanks for listening to Learning Podcasts.