In this lesson, you will learn how to use trait-based builder patterns to enforce strict configuration states at compile time, ensuring your objects are always initialized with the required parameters.
The Problem: Illegal States
When building complex objects, we often need certain fields set before we can call .build(). In a traditional builder, you might return Option or panic if a user forgets a field. With traits, we can move those errors to compile time.
By defining marker traits for different "stages" of a builder, we can restrict which methods are available depending on the state of the configuration.
Composable States
Imagine a Server builder that requires an Address and a Port. We can represent these states as empty structs and use traits to determine if the builder is "Ready".
// Marker structs to represent state
struct NoAddress;
struct HasAddress;
// The builder struct, generic over its state
struct ServerBuilder<State> {
address: Option<String>,
_state: std::marker::PhantomData<State>,
}
impl ServerBuilder<NoAddress> {
pub fn new() -> Self {
Self { address: None, _state: std::marker::PhantomData }
}
pub fn set_address(self, addr: String) -> ServerBuilder<HasAddress> {
ServerBuilder {
address: Some(addr),
_state: std::marker::PhantomData,
}
}
}
impl ServerBuilder<HasAddress> {
pub fn build(self) -> String {
format!("Server running at {}", self.address.unwrap())
}
}
Why This Works
In the code above, the .build() method only exists on ServerBuilder<HasAddress>. If a developer tries to call .build() on a ServerBuilder<NoAddress>, the compiler will throw an error because the method isn't implemented for that specific generic type.
By returning a new type (ServerBuilder<HasAddress>) from set_address, we transition the builder through a state machine. This creates a "typestate" pattern where the builder’s capabilities evolve as you provide information. Because we are using the compiler to track these states, we eliminate the risk of runtime configuration errors entirely.
Key Takeaway: By using generic state markers and trait-based method segregation, you can transform the builder pattern into a compile-time state machine that prevents users from creating invalid object configurations.