Skip to main content
Gabriel's Computer

Type safe builder pattern in Rust

A few days ago I stumbled upon the sguaba library – a spatial math library written primarily by Jon Gjengset. While exploring the codebase, I discovered an interesting Rust pattern that I hadn't encountered before: a type-safe builder pattern.

This pattern fixes the main problems with regular builders - you don't need to wrap everything in Option and you don't need runtime checks to see if required fields are missing.

The Problem with Traditional Builders

The standard Rust builder pattern typically looks like this:

struct PersonBuilder {
    name: Option<String>,
    age: Option<u32>,
    email: Option<String>,
}

impl PersonBuilder {
    fn build(self) -> Result<Person, &'static str> {
        // *nervous sweating intensifies*
        let name = self.name.ok_or("Name is required")?;
        let age = self.age.ok_or("Age is required")?;
        let email = self.email.ok_or("Email is required")?;

        Ok(Person { name, age, email })
    }
}

This approach has several issues:

  1. Everything is optional until it isn't: Fields are wrapped in Option<T> but are actually required, leading to runtime validation.
  2. Runtime errors for compile-time problems: You won't discover missing fields until the code runs.
  3. Boilerplate: Every builder needs validation logic and error handling.

The Type-Safe Builder Solution

Instead of runtime validation, we can use the type system to enforce completeness at compile time. The secret sauce is to use generics with marker types to track which fields have been set.

Implementation Example

Let's say we have this struct:

struct Ned {
    north: f64,
    east: f64,
    down: f64,
}

This models NED coordinates – North, East, and Down from a specific point of reference, commonly used in aviation and robotics.

We can create a builder that uses the type system to track which fields have been set:

struct NedBuilder<X, Y, Z> {
    under_construction: Ned,
    set: (PhantomData<X>, PhantomData<Y>, PhantomData<Z>),
}

PhantomData<T> is Rust's way of including a type in your struct's signature without actually storing data of that type. It's zero-cost at runtime but allows the compiler to track type information for compile-time checks.

Notice that we don't duplicate all the properties from Ned in our NedBuilder struct. We just store the Ned we're building and let the type parameters track completion status.

The implementation uses marker types to track field completion:

struct Set;
struct Unset;

impl NedBuilder<Unset, Unset, Unset> {
    fn new() -> Self {
        Self {
            under_construction: Ned::default(),
            set: (PhantomData, PhantomData, PhantomData),
        }
    }
}

impl<X, Y, Z> NedBuilder<X, Y, Z> {
    fn north(self, north: f64) -> NedBuilder<Set, Y, Z> {
        NedBuilder {
            under_construction: Ned {
                north,
                east: self.under_construction.east,
                down: self.under_construction.down,
            },
            set: (PhantomData::<Set>, self.set.1, self.set.2),
        }
    }

    fn east(self, east: f64) -> NedBuilder<X, Set, Z> {
        NedBuilder {
            under_construction: Ned {
                north: self.under_construction.north,
                east,
                down: self.under_construction.down,
            },
            set: (self.set.0, PhantomData::<Set>, self.set.2),
        }
    }

    fn down(self, down: f64) -> NedBuilder<X, Y, Set> {
        NedBuilder {
            under_construction: Ned {
                north: self.under_construction.north,
                east: self.under_construction.east,
                down,
            },
            set: (self.set.0, self.set.1, PhantomData::<Set>),
        }
    }
}

impl NedBuilder<Set, Set, Set> {
    fn build(self) -> Ned {
        self.under_construction
    }
}

Here's how this works:

  1. Marker types: Set and Unset are empty structs that exist purely to carry type information.

  2. Initial state: The new() method creates a NedBuilder<Unset, Unset, Unset> where nothing has been set yet.

  3. State transitions: Each setter method takes self by value and returns a new builder with one type parameter changed from Unset to Set. For example, north() takes a NedBuilder<X, Y, Z> and returns a NedBuilder<Set, Y, Z>.

  4. Type-level tracking: The type system tracks which fields have been set through the generic parameters.

The magic trick is that the build() method is only implemented for NedBuilder<Set, Set, Set> – a builder where all three coordinates have been set. This means:

  1. You cannot call build() unless all fields are set.
  2. The compiler will refuse to compile incomplete builders.
  3. No runtime checking, no Option unwrapping, no panics.

Usage Example

Here's how you use this pattern:

let ned = NedBuilder::new()
    .north(10.0)
    .east(20.0)
    .down(30.0)
    .build();

This works perfectly because each method call transforms the builder's type:

But what happens if you try to cheat? What if you forget to set a field? The compiler has some thoughts about that:

// This won't compile!
let ned = NedBuilder::new()
    .north(10.0)
    .east(20.0)
    .build(); // Error: no method named `build` found for struct `NedBuilder<Set, Set, Unset>`

The compiler refuses to compile and provides a clear error message indicating exactly what type you have (NedBuilder<Set, Set, Unset>) and why build() isn't available for that type.

Additional Stuff

There are a few more techniques you can use with type-safe builders. For optional fields, you can provide default values and exclude them from type tracking. Since they're not required, they don't need to be part of the type-level state machine. You can just add them as regular methods that return Self:

impl<X, Y, Z> NedBuilder<X, Y, Z> {
    // Required fields (tracked by type system)
    fn north(self, north: f64) -> NedBuilder<Set, Y, Z> { /* ... */ }
    fn east(self, east: f64) -> NedBuilder<X, Set, Z> { /* ... */ }
    fn down(self, down: f64) -> NedBuilder<X, Y, Set> { /* ... */ }

    // Optional field (not tracked by type system)
    fn altitude_reference(mut self, reference: String) -> Self {
        self.under_construction.altitude_reference = Some(reference);
        self
    }
}

You can also add validation to each setter method if you need runtime checks in addition to compile-time safety:

impl<X, Y, Z> NedBuilder<X, Y, Z> {
    fn north(self, north: f64) -> Result<NedBuilder<Set, Y, Z>, &'static str> {
        if north.is_nan() {
            return Err("North coordinate cannot be NaN");
        }
        Ok(NedBuilder {
            under_construction: Ned {
                north,
                east: self.under_construction.east,
                down: self.under_construction.down,
            },
            set: (PhantomData::<Set>, self.set.1, self.set.2),
        })
    }
}

Conclusion

This pattern trades compile time for runtime safety. While the compiler has to work harder to check all the type relationships, it doesn't create bloated binaries. Rust deduplicates the monomorphized functions since the actual code is identical regardless of type parameters, so you get the safety without the binary size penalty.