131: Builder Pattern with Typestate
Difficulty: โญโญโญ Level: Advanced Construct complex objects step by step with a fluent API, where `build()` only compiles after all required fields have been set.The Problem This Solves
A struct with many fields โ some required, some optional โ is annoying to construct. You could add a `new(name, email, ...)` function, but with many fields it becomes unwieldy and easy to mix up. You could use a mutable builder with `Option` fields and check them in `build()`, but then you get runtime panics or `Result` errors for things you know at compile time: "did I call `.name()`?" The typical builder pattern solves ergonomics but not safety. Nothing stops you from writing `UserBuilder::new().age(30).build()` โ forgetting both `name` and `email`. You discover the error at runtime. Typestate builders solve this: the builder type tracks which fields have been set. `UserBuilder<Optional, Optional>` doesn't have a `build()` method. `UserBuilder<Required, Required>` does. If you forget to call `.name()`, the types don't add up and you get a compile error before your code ever runs. Required fields are documented in the type, not just in doc comments.The Intuition
Take the typestate pattern (example 130) and apply it to a builder. Each "slot" in the builder โ name, email, URL, method โ is a phantom type parameter that starts as `Optional` and transitions to `Required` when you provide the value. The builder struct holds the same fields throughout. Only the type changes. `UserBuilder<Optional, Optional>` and `UserBuilder<Required, Required>` are both the same struct in memory โ but different types to the compiler. The `build()` method is in an `impl` block that only applies when all phantom parameters are `Required`. Each setter method takes `self`, sets the field, and returns `self` with the relevant phantom parameter flipped to `Required`. The chain builds up state in types, not in runtime flags.How It Works in Rust
use std::marker::PhantomData;
// Phantom markers โ no data, just type labels
struct Required;
struct Optional;
// Builder: two phantom params track whether name and email have been set
struct UserBuilder<Name, Email> {
name: Option<String>,
email: Option<String>,
age: Option<u32>,
_phantom: PhantomData<(Name, Email)>,
}
// Start: neither field set
impl UserBuilder<Optional, Optional> {
fn new() -> Self {
UserBuilder { name: None, email: None, age: None, _phantom: PhantomData }
}
}
// .name() is available on any builder where Name = Optional
// Returns a builder with Name = Required (Email stays the same)
impl<E> UserBuilder<Optional, E> {
fn name(self, name: &str) -> UserBuilder<Required, E> {
UserBuilder {
name: Some(name.to_string()),
email: self.email,
age: self.age,
_phantom: PhantomData,
}
}
}
// .email() is available on any builder where Email = Optional
impl<N> UserBuilder<N, Optional> {
fn email(self, email: &str) -> UserBuilder<N, Required> {
UserBuilder {
name: self.name,
email: Some(email.to_string()),
age: self.age,
_phantom: PhantomData,
}
}
}
// .age() is always available โ it's optional, so no phantom transition needed
impl<N, E> UserBuilder<N, E> {
fn age(mut self, age: u32) -> Self {
self.age = Some(age);
self
}
}
// build() ONLY exists when BOTH required fields are set
// impl<Required, Optional> does NOT have build()
impl UserBuilder<Required, Required> {
fn build(self) -> User {
User {
name: self.name.unwrap(), // safe: Required means we set it
email: self.email.unwrap(), // safe: Required means we set it
age: self.age,
}
}
}
Usage:
// All of these compile โ โ order doesn't matter
let user = UserBuilder::new().name("Alice").email("a@b.com").build();
let user = UserBuilder::new().email("a@b.com").name("Alice").age(30).build();
// These do NOT compile:
// UserBuilder::new().build(); // missing name and email
// UserBuilder::new().name("Alice").build(); // missing email
With const bool generics (alternative approach):
// Same idea using `const HAS_URL: bool` instead of marker structs
struct HttpRequestBuilder<const HAS_URL: bool, const HAS_METHOD: bool> { ... }
impl HttpRequestBuilder<true, true> {
fn build(self) -> HttpRequest { ... } // only when both are true
}
What This Unlocks
- SDK clients โ AWS SDK, reqwest, and similar libraries use builders so you can't construct a request without required credentials or endpoints.
- Configuration objects โ distinguish "required config" from "optional tuning" in types; `AppConfig<Initialized>` only exists after all required setup is done.
- Type-safe DSLs โ query builders, email composers, document constructors where the type system documents and enforces the construction protocol.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Builder state | Phantom type in record `('name, 'email) user_builder` | Phantom type params `UserBuilder<Name, Email>` |
| Field setting | `set_name : (unset, 'e) t -> (set, 'e) t` | `fn name(self, ...) -> UserBuilder<Required, E>` |
| build() gating | `build : (set, set) t -> user` | `impl UserBuilder<Required, Required> { fn build }` |
| Memory overhead | None โ phantom types erased | None โ `PhantomData` is zero-sized |
// Example 131: Builder Pattern with Typestate
use std::marker::PhantomData;
// Approach 1: Typestate builder โ required fields enforced at compile time
struct Required;
struct Optional;
struct UserBuilder<Name, Email> {
name: Option<String>,
email: Option<String>,
age: Option<u32>,
_phantom: PhantomData<(Name, Email)>,
}
#[derive(Debug, PartialEq)]
struct User {
name: String,
email: String,
age: Option<u32>,
}
impl UserBuilder<Optional, Optional> {
fn new() -> Self {
UserBuilder {
name: None, email: None, age: None,
_phantom: PhantomData,
}
}
}
impl<E> UserBuilder<Optional, E> {
fn name(self, name: &str) -> UserBuilder<Required, E> {
UserBuilder {
name: Some(name.to_string()),
email: self.email,
age: self.age,
_phantom: PhantomData,
}
}
}
impl<N> UserBuilder<N, Optional> {
fn email(self, email: &str) -> UserBuilder<N, Required> {
UserBuilder {
name: self.name,
email: Some(email.to_string()),
age: self.age,
_phantom: PhantomData,
}
}
}
impl<N, E> UserBuilder<N, E> {
fn age(mut self, age: u32) -> Self {
self.age = Some(age);
self
}
}
// build() only available when BOTH required fields are set
impl UserBuilder<Required, Required> {
fn build(self) -> User {
User {
name: self.name.unwrap(),
email: self.email.unwrap(),
age: self.age,
}
}
}
// Approach 2: Generic builder with const bool tracking
struct HttpRequestBuilder<const HAS_URL: bool, const HAS_METHOD: bool> {
url: Option<String>,
method: Option<String>,
headers: Vec<(String, String)>,
}
impl HttpRequestBuilder<false, false> {
fn new() -> Self {
HttpRequestBuilder { url: None, method: None, headers: vec![] }
}
}
impl<const M: bool> HttpRequestBuilder<false, M> {
fn url(self, url: &str) -> HttpRequestBuilder<true, M> {
HttpRequestBuilder { url: Some(url.to_string()), method: self.method, headers: self.headers }
}
}
impl<const U: bool> HttpRequestBuilder<U, false> {
fn method(self, method: &str) -> HttpRequestBuilder<U, true> {
HttpRequestBuilder { url: self.url, method: Some(method.to_string()), headers: self.headers }
}
}
impl<const U: bool, const M: bool> HttpRequestBuilder<U, M> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
#[derive(Debug)]
struct HttpRequest { url: String, method: String, headers: Vec<(String, String)> }
impl HttpRequestBuilder<true, true> {
fn build(self) -> HttpRequest {
HttpRequest {
url: self.url.unwrap(),
method: self.method.unwrap(),
headers: self.headers,
}
}
}
fn main() {
let user = UserBuilder::new()
.name("Alice")
.email("alice@example.com")
.age(30)
.build();
println!("User: {:?}", user);
let req = HttpRequestBuilder::new()
.url("https://api.example.com")
.method("GET")
.header("Authorization", "Bearer token")
.build();
println!("Request: {:?}", req);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_builder_all_fields() {
let user = UserBuilder::new()
.name("Alice")
.email("alice@test.com")
.age(25)
.build();
assert_eq!(user.name, "Alice");
assert_eq!(user.email, "alice@test.com");
assert_eq!(user.age, Some(25));
}
#[test]
fn test_user_builder_required_only() {
let user = UserBuilder::new()
.email("bob@test.com")
.name("Bob")
.build();
assert_eq!(user.name, "Bob");
assert_eq!(user.age, None);
}
#[test]
fn test_http_builder() {
let req = HttpRequestBuilder::new()
.method("POST")
.url("https://api.test.com")
.header("Content-Type", "application/json")
.build();
assert_eq!(req.url, "https://api.test.com");
assert_eq!(req.method, "POST");
assert_eq!(req.headers.len(), 1);
}
}
(* Example 131: Builder Pattern with Typestate *)
(* Approach 1: GADT builder requiring fields *)
type unset = Unset_t
type set = Set_t
type ('name, 'email) user_builder = {
name : string option;
email : string option;
age : int option;
}
let empty_builder : (unset, unset) user_builder =
{ name = None; email = None; age = None }
let set_name name (b : (unset, 'e) user_builder) : (set, 'e) user_builder =
{ b with name = Some name }
let set_email email (b : ('n, unset) user_builder) : ('n, set) user_builder =
{ b with email = Some email }
let set_age age b = { b with age = Some age }
type user = { user_name : string; user_email : string; user_age : int option }
let build (b : (set, set) user_builder) : user =
{ user_name = Option.get b.name;
user_email = Option.get b.email;
user_age = b.age }
(* Approach 2: Module-based builder *)
module type HAS_NAME = sig val name : string end
module type HAS_EMAIL = sig val email : string end
module UserBuilder = struct
type t = { name: string; email: string; age: int option }
let create name email = { name; email; age = None }
let with_age age t = { t with age = Some age }
end
(* Approach 3: Continuation-style builder *)
let build_user ~name ~email ?age () =
{ user_name = name; user_email = email; user_age = age }
(* Tests *)
let () =
let u = empty_builder
|> set_name "Alice"
|> set_email "alice@example.com"
|> set_age 30
|> build in
assert (u.user_name = "Alice");
assert (u.user_email = "alice@example.com");
assert (u.user_age = Some 30);
let u2 = build_user ~name:"Bob" ~email:"bob@test.com" () in
assert (u2.user_name = "Bob");
assert (u2.user_age = None);
Printf.printf "โ All tests passed\n"
๐ Detailed Comparison
Comparison: Builder Pattern with Typestate
OCaml
๐ช Show OCaml equivalent
type ('name, 'email) user_builder = {
name : string option;
email : string option;
}
let set_name name (b : (unset, 'e) user_builder) : (set, 'e) user_builder =
{ b with name = Some name }
let build (b : (set, set) user_builder) : user =
{ user_name = Option.get b.name; user_email = Option.get b.email }Rust
impl UserBuilder<Optional, Optional> {
fn new() -> Self { /* ... */ }
}
impl<E> UserBuilder<Optional, E> {
fn name(self, name: &str) -> UserBuilder<Required, E> { /* ... */ }
}
// Only available when both Required
impl UserBuilder<Required, Required> {
fn build(self) -> User { /* ... */ }
}