Rust
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Ownership and Borrowing
-
Section 4: Structuring Data
-
Section 5: Collections and Error Handling
-
Section 6: Traits and Generics
-
Section 7: Concurrency
-
Section 8: Building for the Web
-
Section 9: Memory and Performance
-
Section 10: More Standard Library and Ecosystem
-
Section 11: Advanced Rust
-
Section 12: Rust for Systems and WebAssembly
-
Section 13: Tooling and Best Practices
-
Section 14: Data Structures and Algorithms in Rust
-
Section 15: Practical Projects
-
Section 16: Interview Practice
-
Section 17: std::collections In Depth
-
Section 18: std::io and std::fs In Depth
-
Section 19: std::net
-
Section 20: std::option and std::result In Depth
-
Section 21: std::iter In Depth
-
Section 22: std::sync In Depth
-
Section 23: std::string and std::str
-
Section 24: Cargo and Crates.io Ecosystem
-
Section 25: Popular Crates Ecosystem
-
Section 26: Rust Memory Model Deep Dive
-
Section 27: More Practice Exercises
-
Section 28: More Interview Practice
-
Section 29: Async Rust Deep Dive
-
Section 30: Tokio Ecosystem In Depth
-
Section 31: Error Handling Ecosystem Deep Dive
-
Section 32: Serde In Depth
-
Section 33: Web Development with Rust Deep Dive
-
Section 34: Database Access Ecosystem
-
Section 35: Rust for Embedded Systems Deep Dive
-
Section 36: Rust Macros In Depth
-
Section 37: Advanced Trait System
-
Section 38: Unsafe Rust In Depth
-
Section 39: Rust CLI Development
-
Section 40: Testing Ecosystem Deep Dive
-
Section 41: WebAssembly Deep Dive
-
Section 42: Rust Design Patterns
-
Section 43: More Data Structures in Rust
-
Section 44: Final Practice Projects
-
Section 45: Rust Performance Optimization
-
Section 46: Rust Ecosystem Tooling
-
Section 47: More Interview and Review
219: The Typestate Pattern
I've seen this pattern in countless code reviews: a developer creates a struct to manage a multi-step process—like a network connection, a payment flow, or a file upload—and uses an internal enum to track the current state. At first glance, it looks clean. But then, the logic starts to leak.
Take a look at this implementation of a simple HttpRequest builder. The goal is to ensure a request has a body before it's sent.
struct Request {
url: String,
body: Option,
is_sent: bool,
}
impl Request {
fn new(url: &str) -> Self {
Self {
url: url.to_string(),
body: None,
is_sent: false,
}
}
fn set_body(&mut self, body: String) {
self.body = Some(body);
}
fn send(&mut self) -> Result<(), String> {
if self.is_sent {
return Err("Request already sent".to_string());
}
if self.body.is_none() {
return Err("Cannot send request without a body".to_string());
}
println!("Sending request to {}...", self.url);
self.is_sent = true;
Ok(())
}
}
fn main() {
let mut req = Request::new("https://api.example.com");
// Oops! I forgot to call set_body()
if let Err(e) = req.send() {
println!("Error: {}", e);
}
}
The Runtime Validation Trap
The problem here isn't that the code doesn't work—it does. It catches the error at runtime and returns a Result. But here is the issue: as a software engineer, I don't want to find out I forgot to set the body when the program is already running in production. I want the compiler to tell me I'm doing something stupid while I'm still writing the code.
In the example above, the send method is available on Request regardless of whether the request is actually "ready." We're relying on runtime checks (if-statements) to enforce business logic. This is a waste of CPU cycles and a source of fragility. If we add more states—like Authenticated or TimedOut—the send method will become a giant mess of nested if checks.
Encoding State into the Type System
We can fix this using the Typestate Pattern. Instead of using a field to track state, we use types. By making the state a generic parameter, we can implement specific methods only for specific states. If a method isn't implemented for a particular state, the code simply won't compile.
Here is how we refactor that request builder. Notice how we use empty structs to represent the states:
// State markers
struct Empty;
struct Ready;
struct Request<S> {
url: String,
body: Option<String>,
_state: std::marker::PhantomData<S>,
}
impl Request<Empty> {
fn new(url: &str) -> Self {
Self {
url: url.to_string(),
body: None,
_state: std::marker::PhantomData,
}
}
// This method consumes the 'Empty' request and returns a 'Ready' request
fn set_body(self, body: String) -> Request<Ready> {
Request {
url: self.url,
body: Some(body),
_state: std::marker::PhantomData,
}
}
}
impl Request<Ready> {
// This method is ONLY available when the state is 'Ready'
fn send(self) {
println!("Sending request to {} with body {:?}...", self.url, self.body);
}
}
fn main() {
let req = Request::new("https://api.example.com");
// req.send(); // ERROR: no method named `send` found for struct `Request`
let ready_req = req.set_body("Hello Rust!".to_string());
ready_req.send(); // This works!
}
Why This is Superior
I love this approach because it transforms a logic error into a compiler error. Let's break down why this works so effectively in Rust:
- Ownership: Because
set_bodytakesselfby value, theRequest<Empty>instance is consumed. You cannot accidentally callset_bodytwice or try to use the "empty" version of the request after it has transitioned to "ready." - Zero Cost:
PhantomDatais a zero-sized type. It doesn't take up any space in the final binary. You get the safety of a state machine without paying a memory penalty. - Discoverability: When you type
req.in your IDE, you only see the methods that are valid for the current state. You don't have to guess which methods require which preconditions.
The Typestate pattern is essentially a way of telling the compiler: "This object evolves over time, and its capabilities change as it does." It's one of the most powerful ways to leverage Rust's type system to build "correct-by-construction" software.
📋 Practical Task
Implementing a Secure File Uploader State Machine
You are building a file upload system. A file must go through exactly these states in order:
Idle → Authenticated → Uploading.
Your task is to implement the Typestate pattern to enforce this flow. Use the following requirements:
- A
Uploader<Idle>should have a methodauthenticate(self, token: String)that returns aUploader<Authenticated>. - A
Uploader<Authenticated>should have a methodstart_upload(self, filename: String)that returns aUploader<Uploading>. - Only a
Uploader<Uploading>should have a methodcomplete(self)that finishes the process. - Ensure that it is impossible to call
start_uploadbeforeauthenticate, orcompletebeforestart_upload.
Bonus: Use std::marker::PhantomData to ensure your state markers don't take up any runtime memory.
There are no comments for now.