Encoding States and Behavior as Types
Learn how to use Rust's type system to encode workflow states as separate types, eliminating invalid states at compile time and removing runtime checks.
The trait object state pattern we saw earlier works, but it carries a few burdens: a Box<dyn State> and an Option just to swap out state objects, runtime checks inside content() that always return "" unless the post is published, and the fact that the State trait forces every state to implement methods that don’t do anything meaningful for it. All of these are clues that the compiler could be doing more work for us.
Rust’s type system can encode the state of a blog post directly into the type of the post itself. Instead of one Post struct with an internal state field, you create a different type for each stage—DraftPost, PendingReviewPost, PublishedPost—and the methods that transition between states consume the old value and return a new one of a different type. The result is a workflow where invalid operations are not guarded by runtime checks but are impossible to even write: the code simply won’t compile.
How the Type-State Pattern Works
The idea is to turn each step of the workflow into its own struct. The data—the blog post’s text—moves from one struct to the next during a transition. Only the final published type exposes a content method. No trait objects, no Option, no dyn.
We’ll walk through the same blog post example: a post starts as a draft, you can add text while it’s a draft, then request a review, approve it, and finally read the published content. A rejection path sends the post back to draft.
Defining the State Types
Start with a DraftPost that holds the post’s text and allows adding content. It has a new constructor and an add_text method. The critical part is request_review, which takes ownership of self and returns a PendingReviewPost.
pub struct DraftPost {
content: String,
}
impl DraftPost {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
When request_review is called, the DraftPost is consumed—you cannot use it again, because its content field has been moved into the PendingReviewPost. This is Rust’s ownership system enforcing that the draft state is done.
PendingReviewPost cannot add text; text can only be added while the post is a draft. That design choice is now enforced by the type system—there simply is no add_text method on PendingReviewPost. All you can do is approve the post or reject it back to draft.
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> PublishedPost {
PublishedPost {
content: self.content,
}
}
pub fn reject(self) -> DraftPost {
DraftPost {
content: self.content,
}
}
}
Finally, PublishedPost is the only type that provides a content method. Reading the text of an unpublished post is now a compile-time error because the method simply doesn’t exist on the other types.
pub struct PublishedPost {
content: String,
}
impl PublishedPost {
pub fn content(&self) -> &str {
&self.content
}
}
Compile‑Time Safety:
With this design, the compiler proves that you never call content() on a draft or pending review post. No runtime checks, no empty‑string placeholder.
Putting the Workflow Together
A typical usage follows a strict chain: create a draft, add text, request review, approve, then read. Each transition consumes the previous value and returns the next. The variable must be rebound (or shadowed) to hold the new state.
fn main() {
let mut draft = DraftPost::new();
draft.add_text("I ate a salad for lunch today");
// draft is consumed here; we now have a PendingReviewPost
let review = draft.request_review();
// approve returns PublishedPost
let published = review.approve();
println!("{}", published.content());
}
If you attempt to call content() before the post is published, the compiler stops you immediately:
let draft = DraftPost::new();
// error[E0599]: no method named `content` found for struct `DraftPost`
// println!("{}", draft.content());
Who Owns the Content?:
Notice that the content field is private and never exposed until the published state. The text moves from DraftPost to PendingReviewPost to PublishedPost through ownership transfer, so there is never a point where it could be read prematurely.
Why This Eliminates Whole Categories of Bugs
In the trait-object version, calling content() on a draft post silently returned an empty string. The method was always there, and a user of the library might wonder why their text wasn’t showing up. With separate types, the question never arises—you cannot ask for content if you don’t have a PublishedPost.
Similarly, state transitions are impossible to misuse. There’s no way to approve a draft without first requesting a review, because DraftPost has no approve method. The only path from draft to published goes through request_review (getting a PendingReviewPost) and then approve. The shape of the code mirrors the business rules.
A rejection also creates a fresh DraftPost. If you want to allow the author to edit after rejection, the returned DraftPost already has an add_text method—the post is back in a state where editing makes sense.
Don’t Forget to Rebind:
Because the transition methods consume self, you must assign the return value to a new variable. If you write draft.request_review(); without binding the result, the PendingReviewPost is dropped, and the content is lost.
Trade‑Offs Compared to the Trait Object Pattern
The type‑state approach gives you zero‑cost abstractions: every method call is resolved at compile time, no heap allocation for a Box, no dynamic dispatch. But it’s not a universal replacement.
- Homogeneous collections are harder. You cannot put
DraftPost,PendingReviewPost, andPublishedPostinto the sameVecwithout wrapping them in an enum or a trait object. If your application needs to store a list of posts of various states and iterate over them, you may need an explicitenum PostStatethat holds each variant, reintroducing some runtime matching. In many workflow scenarios, though, posts are not mixed together in that way; they are processed one at a time. - More types, more clarity. The extra structs may feel like boilerplate at first, but they serve as living documentation. A function signature
fn render_post(post: &PublishedPost)tells you exactly what state the post must be in, without reading the body. - Consuming transitions constrain reuse. Once a
DraftPostis turned into aPendingReviewPost, the draft is gone. If you need to keep a copy of the draft, you’d have to clone the content before transitioning. This is rarely a problem in practice, but it’s worth noting.
Beware of Partially Consumed Structs:
If you add fields to a state struct that don’t implement Copy, moving the entire struct during a transition takes everything. You cannot partially move fields out—Rust doesn’t allow moving a field while leaving the rest intact unless you destructure the whole struct. If you need to keep some metadata across transitions, consider passing it along as a separate value or wrapping it in an Option that you can take.
When to Reach for This Pattern
Encoding states as types shines whenever:
- The lifecycle of an object has a small, well‑defined set of sequential states.
- You want to prevent invalid operations at compile time rather than relying on tests or documentation.
- The code will be maintained by a team where the compiler acting as a guardian reduces accidental misuse.
Common real‑world uses include network connection states (disconnected → connecting → authenticated), file open modes, or order processing pipelines. In each case, the type‑state pattern makes the legal transitions explicit in the API.
If you need to store many items in different states in the same collection, or if the state machine is complex and likely to gain many new states that must be handled polymorphically, the trait object approach may fit better. Both patterns are valid Rust—the difference is which guarantees you want the compiler to enforce and how much you value a zero‑cost abstraction.
Summary
Instead of managing an internal state field and guarding every method with runtime checks, you can use Rust’s type system to represent each state as its own type. Transitions become methods that consume the current type and return the next, moving the data along while making invalid operations impossible. This eliminates Option, Box<dyn State>, and the need for placeholder content() implementations. The trade‑off is less flexibility for heterogeneous collections, but for sequential workflows the safety and clarity gained are substantial. The same idea underlies many idiomatic Rust APIs, and understanding it will help you design interfaces that are hard to misuse—by making the type checker your first line of defense.