Skip to Content
Course content

219: The Typestate Pattern

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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_body takes self by value, the Request<Empty> instance is consumed. You cannot accidentally call set_body twice or try to use the "empty" version of the request after it has transitioned to "ready."
  • Zero Cost: PhantomData is 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 method authenticate(self, token: String) that returns a Uploader<Authenticated>.
  • A Uploader<Authenticated> should have a method start_upload(self, filename: String) that returns a Uploader<Uploading>.
  • Only a Uploader<Uploading> should have a method complete(self) that finishes the process.
  • Ensure that it is impossible to call start_upload before authenticate, or complete before start_upload.

Bonus: Use std::marker::PhantomData to ensure your state markers don't take up any runtime memory.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.