Builder Pattern and Typestate
A struct with a handful of required fields and a dozen optional ones is awkward to construct directly, either every caller fills in every field, including ones that should default to something sensible, or you write a small army of new, with_timeout, with_retries constructor variants. The builder pattern fixes the ergonomics. Taken further, the typestate pattern uses the same fluent-method shape to make invalid call sequences a compile error instead of a runtime panic.
The Basic Builder
A builder accumulates configuration through chained method calls that each take and return Self, then produces the final value with .build().
struct HttpClient {
base_url: String,
timeout_secs: u32,
retries: u32,
}
struct HttpClientBuilder {
base_url: String,
timeout_secs: u32,
retries: u32,
}
impl HttpClientBuilder {
fn new(base_url: impl Into<String>) -> Self {
Self { base_url: base_url.into(), timeout_secs: 30, retries: 0 }
}
fn timeout_secs(mut self, secs: u32) -> Self {
self.timeout_secs = secs;
self
}
fn retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
fn build(self) -> HttpClient {
HttpClient {
base_url: self.base_url,
timeout_secs: self.timeout_secs,
retries: self.retries,
}
}
}
let client = HttpClientBuilder::new("https://api.example.com")
.timeout_secs(10)
.retries(3)
.build();
Every method specifies only the field it's overriding; everything else keeps the default set in new. Each method consumes self and returns a new Self, which is why the chain reads left to right, mut self rebinds the same value with one field changed and hands it to the next call. This is the impl Into<String> pattern from Newtype Pattern and From/Into Conversions, letting callers pass a &str literal without an explicit .to_string().
Required Fields That Can't Be Skipped
The plain builder above has no way to express "this field has no sensible default, .build() should fail if it was never set." Track required fields as Option<T> inside the builder, and have .build() return a Result:
#[derive(Default)]
struct RequestBuilder {
url: Option<String>,
method: Option<String>,
}
impl RequestBuilder {
fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
fn method(mut self, method: impl Into<String>) -> Self {
self.method = Some(method.into());
self
}
fn build(self) -> Result<Request, String> {
Ok(Request {
url: self.url.ok_or("url is required")?,
method: self.method.unwrap_or_else(|| "GET".to_string()),
})
}
}
#[derive(Default)] on the builder itself (every field starts as None) removes the need to hand-write a new() that just sets everything to None. .ok_or(...)? turns a missing required field into an Err at .build() time, the same ?-based error handling from Error Handling in Practice, rather than an .unwrap() panic deep inside construction.
Typestate: Making Invalid Sequences Not Compile
A regular builder catches a missing field at .build() time, a runtime check. Typestate goes further: it encodes which methods are even callable into the type itself, so calling something out of order is a compile error, not a Result you have to remember to check.
The mechanism is a generic parameter that's never actually stored as data, just used to tag which "state" a value is in:
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct Connection<State> {
address: String,
_state: PhantomData<State>,
}
impl Connection<Disconnected> {
fn new(address: impl Into<String>) -> Self {
Connection { address: address.into(), _state: PhantomData }
}
fn connect(self) -> Connection<Connected> {
println!("connecting to {}", self.address);
Connection { address: self.address, _state: PhantomData }
}
}
impl Connection<Connected> {
fn send(&self, data: &str) {
println!("sending: {data}");
}
fn disconnect(self) -> Connection<Disconnected> {
Connection { address: self.address, _state: PhantomData }
}
}
let conn = Connection::new("127.0.0.1:8080");
// conn.send("data"); // ERROR: no method `send` on `Connection<Disconnected>`
let conn = conn.connect();
conn.send("data"); // fine, only available once Connected
PhantomData<State> takes up zero bytes at runtime, it exists purely so the compiler can attach a marker type to the struct without an actual field of that type. send is defined only in the impl Connection<Connected> block, so it's simply not a method that exists on Connection<Disconnected>, there's no Result to check and no panic path, the invalid call is rejected the same way calling a method that was never defined would be.
When Typestate Is Worth It
Typestate adds real complexity: every state needs its own marker type and impl block, and the API surface gets harder to read at a glance compared to one struct with runtime checks. It earns that cost when:
- The wrong call order is a real, recurring mistake (sending data before connecting, committing a transaction twice, building a request before setting required headers).
- The type is part of a public API other people will call without reading the implementation, the compile error documents the valid sequence far better than a panic message would.
For an internal helper struct used in one place, a regular builder with a Result-returning .build(), or even just a debug assertion, is almost always enough. Reach for typestate when the cost of a misuse bug reaching production outweighs the extra impl blocks.
Key Takeaways
- A builder accumulates configuration through chained
fn method(mut self, ...) -> Selfcalls, with sensible defaults for anything the caller doesn't override. - Track required fields as
Option<T>in the builder and have.build()return aResult, turning a missing field into a handled error instead of an.unwrap()panic. #[derive(Default)]on the builder struct removes the boilerplate of anew()that just sets every field toNone.- Typestate encodes valid state transitions as separate types (often via a zero-cost
PhantomData<State>marker), so calling a method that's invalid in the current state is a compile error, not a runtime check. - Reach for typestate when misuse is a real, recurring risk in a public API; for internal one-off structs, a regular builder with a
Result-returning.build()is usually enough.