Builder Pattern with Typestate
Tutorial Video
Text description (accessibility)
This video demonstrates the "Builder Pattern with Typestate" functional Rust example. Difficulty level: Advanced. Key concepts covered: Type-Level Programming, Design Patterns, Zero-Cost Abstractions. Construct a complex struct step-by-step with a fluent API where `build()` only compiles after every required field has been provided. Key difference from OCaml: 1. **Phantom syntax:** OCaml uses unconstrained type variables `('a, 'b)`
Tutorial
The Problem
Construct a complex struct step-by-step with a fluent API where build() only
compiles after every required field has been provided. Forgetting a required
field is a compile-time error, not a runtime panic or a Result error.
🎯 Learning Outcomes
PhantomData<T> lets Rust track type information with zero runtime costimpl<...> blocks gates availability by type🦀 The Rust Way
Rust uses two zero-sized marker structs (Missing, Present) and
PhantomData<(N, E)> to achieve the same tracking. Each setter is placed in
its own impl block constrained to the Missing state for that slot:
impl<E> UserBuilder<Missing, E>. The return type transitions the slot to
Present. The build() method exists only on
impl UserBuilder<Present, Present>. Because all types are zero-sized or
erased, the pattern has zero runtime overhead — it is pure compile-time
bookkeeping.
Code Example
use std::marker::PhantomData;
pub struct Missing;
pub struct Present;
pub struct UserBuilder<N, E> {
name: Option<String>,
email: Option<String>,
age: Option<u32>,
_phantom: PhantomData<(N, E)>,
}
// .name() available only when N = Missing; transitions to Present
impl<E> UserBuilder<Missing, E> {
pub fn name(self, name: &str) -> UserBuilder<Present, E> {
UserBuilder { name: Some(name.to_string()), ..self.into_next() }
}
}
// .build() available only when both N = Present and E = Present
impl UserBuilder<Present, Present> {
pub fn build(self) -> User {
User {
name: self.name.unwrap(),
email: self.email.unwrap(),
age: self.age,
}
}
}Key Differences
('a, 'b) directly in the type alias; Rust uses PhantomData<(N, E)> as an actual
zero-sized field.
build by requiring (set, set) in its argument type. Rust gates it with impl UserBuilder<Present, Present> —
the method literally does not exist on other instantiations.
{ b with name = ... } copies all other fields automatically. Rust must name each field in the
new struct literal, transferring them from self explicitly.
self by value so the old builder isconsumed at each transition — no aliasing, no double-use. OCaml copies the record functionally, achieving the same single-use semantics.
OCaml Approach
OCaml uses phantom type variables in a record type ('name, 'email) user_builder
where 'name and 'email never appear in the concrete fields. Functions like
set_name accept (unset, 'e) user_builder and return (set, 'e) user_builder,
so the compiler rejects a second call to set_name and rejects build unless
both slots carry the set phantom. Field records are copied structurally with
{ b with field = value }.
Full Source
#![allow(clippy::all)]
// Example 131: Builder Pattern with Typestate
//
// The typestate builder encodes which required fields have been set directly in
// the type parameters. `UserBuilder<Missing, Missing>` has no `build()` method.
// `UserBuilder<Present, Present>` does. Forgetting a required field is a
// *compile-time* error, not a runtime panic or a `Result` error.
use std::marker::PhantomData;
// ---------------------------------------------------------------------------
// Marker types — zero-sized, carry only type information
// ---------------------------------------------------------------------------
/// A required field that has not yet been provided.
pub struct Missing;
/// A required field that has been provided.
pub struct Present;
// ---------------------------------------------------------------------------
// Approach 1: UserBuilder — name + email required, age optional
// ---------------------------------------------------------------------------
#[derive(Debug, PartialEq)]
pub struct User {
pub name: String,
pub email: String,
pub age: Option<u32>,
}
/// A builder whose type parameters `N` and `E` track whether `name` and
/// `email` have been set. Both must be `Present` before `build()` is callable.
pub struct UserBuilder<N, E> {
name: Option<String>,
email: Option<String>,
age: Option<u32>,
_phantom: PhantomData<(N, E)>,
}
// --- Initial state: nothing set ---
impl UserBuilder<Missing, Missing> {
pub fn new() -> Self {
UserBuilder {
name: None,
email: None,
age: None,
_phantom: PhantomData,
}
}
}
impl Default for UserBuilder<Missing, Missing> {
fn default() -> Self {
Self::new()
}
}
// --- Setting `name` transitions N: Missing → Present ---
impl<E> UserBuilder<Missing, E> {
/// Providing a name transitions the builder from `Missing` to `Present`
/// for the name slot. The email slot state `E` is preserved unchanged.
pub fn name(self, name: &str) -> UserBuilder<Present, E> {
UserBuilder {
name: Some(name.to_string()),
email: self.email,
age: self.age,
_phantom: PhantomData,
}
}
}
// --- Setting `email` transitions E: Missing → Present ---
impl<N> UserBuilder<N, Missing> {
/// Providing an email transitions the builder from `Missing` to `Present`
/// for the email slot. The name slot state `N` is preserved unchanged.
pub fn email(self, email: &str) -> UserBuilder<N, Present> {
UserBuilder {
name: self.name,
email: Some(email.to_string()),
age: self.age,
_phantom: PhantomData,
}
}
}
// --- Optional field: `age` is available in all states ---
impl<N, E> UserBuilder<N, E> {
pub fn age(mut self, age: u32) -> Self {
self.age = Some(age);
self
}
}
// --- `build()` only exists when both N = Present and E = Present ---
impl UserBuilder<Present, Present> {
/// Infallible: the types guarantee that `name` and `email` are both `Some`.
pub fn build(self) -> User {
User {
// SAFETY: Present state guarantees these fields were set.
name: self.name.expect("Present guarantees name is Some"),
email: self.email.expect("Present guarantees email is Some"),
age: self.age,
}
}
}
// ---------------------------------------------------------------------------
// Approach 2: HttpRequestBuilder — url + method required, body optional
// ---------------------------------------------------------------------------
// A second demonstration of the same pattern with different required fields.
#[derive(Debug, PartialEq)]
pub struct HttpRequest {
pub url: String,
pub method: String,
pub body: Option<String>,
}
pub struct HttpRequestBuilder<U, M> {
url: Option<String>,
method: Option<String>,
body: Option<String>,
_phantom: PhantomData<(U, M)>,
}
impl HttpRequestBuilder<Missing, Missing> {
pub fn new() -> Self {
HttpRequestBuilder {
url: None,
method: None,
body: None,
_phantom: PhantomData,
}
}
}
impl Default for HttpRequestBuilder<Missing, Missing> {
fn default() -> Self {
Self::new()
}
}
impl<M> HttpRequestBuilder<Missing, M> {
pub fn url(self, url: &str) -> HttpRequestBuilder<Present, M> {
HttpRequestBuilder {
url: Some(url.to_string()),
method: self.method,
body: self.body,
_phantom: PhantomData,
}
}
}
impl<U> HttpRequestBuilder<U, Missing> {
pub fn method(self, method: &str) -> HttpRequestBuilder<U, Present> {
HttpRequestBuilder {
url: self.url,
method: Some(method.to_string()),
body: self.body,
_phantom: PhantomData,
}
}
}
impl<U, M> HttpRequestBuilder<U, M> {
pub fn body(mut self, body: &str) -> Self {
self.body = Some(body.to_string());
self
}
}
impl HttpRequestBuilder<Present, Present> {
pub fn build(self) -> HttpRequest {
HttpRequest {
url: self.url.expect("Present guarantees url is Some"),
method: self.method.expect("Present guarantees method is Some"),
body: self.body,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- UserBuilder tests ---
#[test]
fn test_user_with_required_fields_only() {
let user = UserBuilder::new()
.name("Alice")
.email("alice@example.com")
.build();
assert_eq!(
user,
User {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
age: None,
}
);
}
#[test]
fn test_user_with_all_fields() {
let user = UserBuilder::new()
.name("Bob")
.email("bob@example.com")
.age(30)
.build();
assert_eq!(
user,
User {
name: "Bob".to_string(),
email: "bob@example.com".to_string(),
age: Some(30),
}
);
}
#[test]
fn test_user_email_before_name_order_independent() {
// The builder accepts fields in any order — both chains compile.
let user_a = UserBuilder::new()
.name("Carol")
.email("carol@example.com")
.build();
let user_b = UserBuilder::new()
.email("carol@example.com")
.name("Carol")
.build();
assert_eq!(user_a, user_b);
}
#[test]
fn test_user_age_can_be_set_at_any_point_in_chain() {
let user = UserBuilder::new()
.age(25)
.name("Dave")
.email("dave@example.com")
.build();
assert_eq!(user.age, Some(25));
assert_eq!(user.name, "Dave");
}
#[test]
fn test_user_default_is_same_as_new() {
// Compile-time check: UserBuilder::default() produces Missing, Missing.
let user = UserBuilder::default()
.name("Eve")
.email("eve@example.com")
.build();
assert_eq!(user.name, "Eve");
}
// --- HttpRequestBuilder tests ---
#[test]
fn test_http_get_request_no_body() {
let req = HttpRequestBuilder::new()
.url("https://api.example.com/users")
.method("GET")
.build();
assert_eq!(
req,
HttpRequest {
url: "https://api.example.com/users".to_string(),
method: "GET".to_string(),
body: None,
}
);
}
#[test]
fn test_http_post_request_with_body() {
let req = HttpRequestBuilder::new()
.method("POST")
.url("https://api.example.com/users")
.body(r#"{"name":"Alice"}"#)
.build();
assert_eq!(req.method, "POST");
assert_eq!(req.body, Some(r#"{"name":"Alice"}"#.to_string()));
}
#[test]
fn test_http_builder_order_independent() {
let req_a = HttpRequestBuilder::new()
.url("https://example.com")
.method("DELETE")
.build();
let req_b = HttpRequestBuilder::new()
.method("DELETE")
.url("https://example.com")
.build();
assert_eq!(req_a, req_b);
}
}#[cfg(test)]
mod tests {
use super::*;
// --- UserBuilder tests ---
#[test]
fn test_user_with_required_fields_only() {
let user = UserBuilder::new()
.name("Alice")
.email("alice@example.com")
.build();
assert_eq!(
user,
User {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
age: None,
}
);
}
#[test]
fn test_user_with_all_fields() {
let user = UserBuilder::new()
.name("Bob")
.email("bob@example.com")
.age(30)
.build();
assert_eq!(
user,
User {
name: "Bob".to_string(),
email: "bob@example.com".to_string(),
age: Some(30),
}
);
}
#[test]
fn test_user_email_before_name_order_independent() {
// The builder accepts fields in any order — both chains compile.
let user_a = UserBuilder::new()
.name("Carol")
.email("carol@example.com")
.build();
let user_b = UserBuilder::new()
.email("carol@example.com")
.name("Carol")
.build();
assert_eq!(user_a, user_b);
}
#[test]
fn test_user_age_can_be_set_at_any_point_in_chain() {
let user = UserBuilder::new()
.age(25)
.name("Dave")
.email("dave@example.com")
.build();
assert_eq!(user.age, Some(25));
assert_eq!(user.name, "Dave");
}
#[test]
fn test_user_default_is_same_as_new() {
// Compile-time check: UserBuilder::default() produces Missing, Missing.
let user = UserBuilder::default()
.name("Eve")
.email("eve@example.com")
.build();
assert_eq!(user.name, "Eve");
}
// --- HttpRequestBuilder tests ---
#[test]
fn test_http_get_request_no_body() {
let req = HttpRequestBuilder::new()
.url("https://api.example.com/users")
.method("GET")
.build();
assert_eq!(
req,
HttpRequest {
url: "https://api.example.com/users".to_string(),
method: "GET".to_string(),
body: None,
}
);
}
#[test]
fn test_http_post_request_with_body() {
let req = HttpRequestBuilder::new()
.method("POST")
.url("https://api.example.com/users")
.body(r#"{"name":"Alice"}"#)
.build();
assert_eq!(req.method, "POST");
assert_eq!(req.body, Some(r#"{"name":"Alice"}"#.to_string()));
}
#[test]
fn test_http_builder_order_independent() {
let req_a = HttpRequestBuilder::new()
.url("https://example.com")
.method("DELETE")
.build();
let req_b = HttpRequestBuilder::new()
.method("DELETE")
.url("https://example.com")
.build();
assert_eq!(req_a, req_b);
}
}
Deep Comparison
OCaml vs Rust: Builder Pattern with Typestate
Side-by-Side Code
OCaml
(* Phantom types encode which fields have been set *)
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 }
(* set_name transitions the first phantom from unset to set *)
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 }
(* build only accepts (set, set) — rejected for (unset, _) or (_, unset) *)
let build (b : (set, set) user_builder) : user =
{ user_name = Option.get b.name;
user_email = Option.get b.email;
user_age = b.age }
Rust (idiomatic — phantom type parameters on a struct)
use std::marker::PhantomData;
pub struct Missing;
pub struct Present;
pub struct UserBuilder<N, E> {
name: Option<String>,
email: Option<String>,
age: Option<u32>,
_phantom: PhantomData<(N, E)>,
}
// .name() available only when N = Missing; transitions to Present
impl<E> UserBuilder<Missing, E> {
pub fn name(self, name: &str) -> UserBuilder<Present, E> {
UserBuilder { name: Some(name.to_string()), ..self.into_next() }
}
}
// .build() available only when both N = Present and E = Present
impl UserBuilder<Present, Present> {
pub fn build(self) -> User {
User {
name: self.name.unwrap(),
email: self.email.unwrap(),
age: self.age,
}
}
}
Rust (functional — shows the field-by-field transition chain)
// Usage — each call changes the phantom type, enforced at compile time:
let user = UserBuilder::new() // UserBuilder<Missing, Missing>
.name("Alice") // UserBuilder<Present, Missing>
.email("alice@example.com") // UserBuilder<Present, Present>
.age(30) // UserBuilder<Present, Present> (unchanged)
.build(); // User — only legal because both are Present
// This does NOT compile — no .build() on UserBuilder<Present, Missing>:
// let _ = UserBuilder::new().name("Alice").build();
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Missing-field marker | type unset = Unset_t | pub struct Missing; |
| Present-field marker | type set = Set_t | pub struct Present; |
| Builder type | ('name, 'email) user_builder | UserBuilder<N, E> |
| Initial state | (unset, unset) user_builder | UserBuilder<Missing, Missing> |
| After setting name | (set, 'e) user_builder | UserBuilder<Present, E> |
| Build constraint | (set, set) user_builder | impl UserBuilder<Present, Present> |
| Phantom runtime cost | Zero (type-erased) | Zero (PhantomData is zero-sized) |
Key Insights
('name, 'email) user_builder carries 'name and 'email only in
phantom position — they never appear in the fields themselves, but the
compiler tracks them. Rust achieves the same with PhantomData<(N, E)>.
set_name takes (unset, 'e) user_builder and returns (set, 'e) user_builder. In Rust,
each setter is in a specific impl<...> block: impl<E> UserBuilder<Missing, E>
means the method is only callable when the name slot is Missing, and
it returns a builder with Present in that slot.
build() is gated by impl specialisation.** OCaml restricts build by its argument type (set, set) user_builder. Rust restricts it by
implementing build only on impl UserBuilder<Present, Present>. The
compiler rejects calls to build() on any other combination.
the other field's state (impl<E> UserBuilder<Missing, E>), callers can
provide required fields in any order. Both name().email() and
email().name() produce UserBuilder<Present, Present>.
before runtime. In Rust, PhantomData has size zero and is compiled away
entirely. The builder struct is exactly as large as its concrete fields.
When to Use Each Style
Use the typestate builder when: you have a struct with multiple required
fields and want the compiler — not runtime assertions — to guarantee that
callers cannot forget them. The API becomes self-documenting: missing a
required field is a type error, not a Result::Err or a panic.
**Use a plain mutable builder with Result<T, E> from build() when:**
the set of required vs. optional fields is dynamic, or when you have so many
required fields that the combinatorial explosion of phantom parameters becomes
unmanageable (> 4–5 parameters). At that point, consider a proc-macro crate
such as typed-builder which generates the typestate boilerplate for you.
Exercises
name: String) and enforce at compile time using typestate that build() cannot be called before set_name is invoked.merge that combines two partially-filled builders by preferring non-None values from the right builder.send() is only available after both method and URL are set.