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 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:
Option<T>
but are actually required, leading to runtime validation.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.
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:
Marker types: Set
and Unset
are empty structs that exist purely to carry type information.
Initial state: The new()
method creates a NedBuilder<Unset, Unset, Unset>
where nothing has been set yet.
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>
.
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:
build()
unless all fields are set.Option
unwrapping, no panics.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:
NedBuilder::new()
→ NedBuilder<Unset, Unset, Unset>
.north(10.0)
→ NedBuilder<Set, Unset, Unset>
.east(20.0)
→ NedBuilder<Set, Set, Unset>
.down(30.0)
→ NedBuilder<Set, Set, Set>
.build()
→ Ned
✨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.
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),
})
}
}
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.