In this lesson, you will learn how to use Rust’s type system to enforce a specific sequence of operations, making invalid states impossible to represent at compile time.
The Problem: Logical Ordering
Often, we build APIs where methods must be called in a strict order. For example, a Post might need to be Drafted, then Reviewed, and finally Published. If you use a simple struct Post { state: String }, a developer could accidentally call publish() on a Draft without it being reviewed. This leads to runtime bugs.
The Solution: Typestate
The Typestate pattern moves this logic from runtime checks into the type system. Instead of one struct with a state field, we define a different type for each state. We then transition the object by consuming it (self) and returning a new instance representing the next state.
Consider this workflow:
struct Draft;
struct Published;
struct Post<State> {
content: String,
state: State,
}
impl Post<Draft> {
fn new(content: String) -> Self {
Post { content, state: Draft }
}
fn publish(self) -> Post<Published> {
Post {
content: self.content,
state: Published,
}
}
}
// Post<Draft> does not have a 'delete' method,
// but Post<Published> might!
Why this is powerful
- Compile-Time Safety: If you try to call
publish()on aPost<Published>, the code won't compile because the method is only defined forPost<Draft>. - Zero-Cost: Because these types exist only to guide the compiler, they are "zero-cost." They vanish during compilation, leaving only the machine code behind.
- Intentional APIs: You remove the need for
OptionorResulttypes inside your struct fields to track "if a value exists yet," because the type itself tells you exactly what is present.
By encoding the state into the type, your API becomes self-documenting. The user cannot physically perform an action that isn't allowed in the current phase of the lifecycle.
Key Takeaway: By using generic states and consuming self to transition between types, you move logical errors from runtime crashes to compile-time compiler errors.