ExamplesBy LevelBy TopicLearning Paths
131 Advanced

Builder Pattern with Typestate

Type-Level ProgrammingDesign PatternsZero-Cost Abstractions

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

  • • How phantom type parameters encode presence/absence of required fields
  • • How PhantomData<T> lets Rust track type information with zero runtime cost
  • • Why splitting methods across impl<...> blocks gates availability by type
  • • How the same typestate technique scales to multiple independent required fields
  • 🦀 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

  • Phantom syntax: OCaml uses unconstrained type variables ('a, 'b)
  • directly in the type alias; Rust uses PhantomData<(N, E)> as an actual zero-sized field.

  • Method gating: OCaml gates 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.

  • Record update vs struct construction: OCaml's { b with name = ... }
  • copies all other fields automatically. Rust must name each field in the new struct literal, transferring them from self explicitly.

  • Ownership: Rust's builder takes self by value so the old builder is
  • consumed 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust
    Missing-field markertype unset = Unset_tpub struct Missing;
    Present-field markertype set = Set_tpub struct Present;
    Builder type('name, 'email) user_builderUserBuilder<N, E>
    Initial state(unset, unset) user_builderUserBuilder<Missing, Missing>
    After setting name(set, 'e) user_builderUserBuilder<Present, E>
    Build constraint(set, set) user_builderimpl UserBuilder<Present, Present>
    Phantom runtime costZero (type-erased)Zero (PhantomData is zero-sized)

    Key Insights

  • Phantom types in OCaml are polymorphic type parameters. The record
  • ('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)>.

  • Transitions are function signatures. In OCaml, 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.

  • Order independence is free. Because each setter is parameterised over
  • 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>.

  • Zero runtime cost. In both languages the phantom types are erased
  • 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

  • Add a required field to the builder (e.g., a non-optional name: String) and enforce at compile time using typestate that build() cannot be called before set_name is invoked.
  • Extend the builder to support optional fields with default values, and implement merge that combines two partially-filled builders by preferring non-None values from the right builder.
  • Implement a fluent HTTP request builder: method, URL, headers (multiple allowed), optional body — using the typestate pattern to ensure send() is only available after both method and URL are set.
  • Open Source Repos