Rust Builder Pattern: Constructing Complex Types
Rust doesn't support optional function parameters or method overloading. When you need to construct types with many fields—especially when some are optional—you face a choice between verbose...
Key Insights
- Builders solve Rust’s lack of optional parameters and method overloading, making complex type construction ergonomic without sacrificing type safety
- The typestate pattern with phantom types enables compile-time enforcement of required fields, catching configuration errors before runtime
- The
derive_buildercrate eliminates boilerplate while maintaining flexibility for custom validation and complex initialization logic
Why Builders in Rust?
Rust doesn’t support optional function parameters or method overloading. When you need to construct types with many fields—especially when some are optional—you face a choice between verbose constructors or compromising on usability. The builder pattern elegantly solves this problem.
Consider a server configuration struct with numerous settings:
struct ServerConfig {
host: String,
port: u16,
max_connections: usize,
timeout_ms: u64,
enable_logging: bool,
log_level: String,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
worker_threads: usize,
}
Direct construction becomes painful:
let config = ServerConfig {
host: "localhost".to_string(),
port: 8080,
max_connections: 1000,
timeout_ms: 5000,
enable_logging: true,
log_level: "info".to_string(),
tls_cert_path: None,
tls_key_path: None,
worker_threads: 4,
};
Every instantiation requires specifying all fields, even when defaults would suffice. Builders provide a cleaner, more maintainable alternative.
Basic Builder Implementation
The builder pattern uses a separate struct that accumulates configuration through method chaining, then produces the final type via a build() method.
Here’s a basic implementation:
pub struct ServerConfig {
host: String,
port: u16,
max_connections: usize,
timeout_ms: u64,
worker_threads: usize,
}
pub struct ServerConfigBuilder {
host: String,
port: u16,
max_connections: usize,
timeout_ms: u64,
worker_threads: usize,
}
impl ServerConfigBuilder {
pub fn new() -> Self {
Self {
host: "localhost".to_string(),
port: 8080,
max_connections: 1000,
timeout_ms: 5000,
worker_threads: 4,
}
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn max_connections(mut self, max: usize) -> Self {
self.max_connections = max;
self
}
pub fn build(self) -> ServerConfig {
ServerConfig {
host: self.host,
port: self.port,
max_connections: self.max_connections,
timeout_ms: self.timeout_ms,
worker_threads: self.worker_threads,
}
}
}
Usage becomes clean and self-documenting:
let config = ServerConfigBuilder::new()
.host("0.0.0.0")
.port(3000)
.max_connections(500)
.build();
Each setter method takes self by value and returns Self, enabling method chaining. Only the fields you care about need specification.
Handling Required vs Optional Fields
Real-world configurations often have required fields that must be set. You have two approaches: runtime validation with Option<T> or compile-time enforcement with the typestate pattern.
Runtime Validation Approach
pub struct DatabaseConfigBuilder {
connection_string: Option<String>, // Required
pool_size: usize, // Optional with default
timeout_ms: Option<u64>, // Optional
}
impl DatabaseConfigBuilder {
pub fn new() -> Self {
Self {
connection_string: None,
pool_size: 10,
timeout_ms: None,
}
}
pub fn connection_string(mut self, s: impl Into<String>) -> Self {
self.connection_string = Some(s.into());
self
}
pub fn pool_size(mut self, size: usize) -> Self {
self.pool_size = size;
self
}
pub fn build(self) -> Result<DatabaseConfig, String> {
let connection_string = self.connection_string
.ok_or("connection_string is required")?;
Ok(DatabaseConfig {
connection_string,
pool_size: self.pool_size,
timeout_ms: self.timeout_ms,
})
}
}
Compile-Time Typestate Pattern
For critical required fields, enforce correctness at compile time using phantom types:
use std::marker::PhantomData;
struct Set;
struct Unset;
pub struct DatabaseConfigBuilder<ConnState = Unset> {
connection_string: Option<String>,
pool_size: usize,
_conn_state: PhantomData<ConnState>,
}
impl DatabaseConfigBuilder<Unset> {
pub fn new() -> Self {
Self {
connection_string: None,
pool_size: 10,
_conn_state: PhantomData,
}
}
pub fn connection_string(
self,
s: impl Into<String>,
) -> DatabaseConfigBuilder<Set> {
DatabaseConfigBuilder {
connection_string: Some(s.into()),
pool_size: self.pool_size,
_conn_state: PhantomData,
}
}
}
impl<ConnState> DatabaseConfigBuilder<ConnState> {
pub fn pool_size(mut self, size: usize) -> Self {
self.pool_size = size;
self
}
}
impl DatabaseConfigBuilder<Set> {
pub fn build(self) -> DatabaseConfig {
DatabaseConfig {
connection_string: self.connection_string.unwrap(),
pool_size: self.pool_size,
}
}
}
Now build() only exists when the connection string is set. Attempting to build without it fails at compile time.
The derive_builder Crate
Hand-rolling builders creates boilerplate. The derive_builder crate automates this while preserving type safety.
Add to Cargo.toml:
[dependencies]
derive_builder = "0.13"
Then derive the builder:
use derive_builder::Builder;
#[derive(Builder, Debug)]
#[builder(setter(into))]
pub struct ServerConfig {
host: String,
port: u16,
#[builder(default = "1000")]
max_connections: usize,
#[builder(default = "5000")]
timeout_ms: u64,
#[builder(default, setter(strip_option))]
tls_cert_path: Option<String>,
}
// Usage
let config = ServerConfigBuilder::default()
.host("0.0.0.0")
.port(8080)
.tls_cert_path("cert.pem") // No need for Some()
.build()
.unwrap();
Custom validation integrates cleanly:
#[derive(Builder)]
#[builder(build_fn(validate = "Self::validate"))]
pub struct ApiConfig {
#[builder(setter(into))]
api_key: String,
#[builder(default = "100")]
rate_limit: u32,
}
impl ApiConfigBuilder {
fn validate(&self) -> Result<(), String> {
if let Some(key) = &self.api_key {
if key.len() < 16 {
return Err("API key must be at least 16 characters".to_string());
}
}
Ok(())
}
}
Advanced Patterns: Consuming Builders and Validation
Builders should be consumed by build() to prevent accidental reuse. Return Result<T, E> when validation can fail:
#[derive(Debug)]
pub enum ConfigError {
MissingField(&'static str),
InvalidValue(String),
}
pub struct HttpClientBuilder {
base_url: Option<String>,
timeout_ms: u64,
max_retries: u32,
}
impl HttpClientBuilder {
pub fn new() -> Self {
Self {
base_url: None,
timeout_ms: 30_000,
max_retries: 3,
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
pub fn build(self) -> Result<HttpClient, ConfigError> {
let base_url = self.base_url
.ok_or(ConfigError::MissingField("base_url"))?;
if self.timeout_ms == 0 {
return Err(ConfigError::InvalidValue(
"timeout must be greater than 0".to_string()
));
}
Ok(HttpClient {
base_url,
timeout_ms: self.timeout_ms,
max_retries: self.max_retries,
})
}
}
Real-World Example: HTTP Client Builder
Here’s a complete HTTP client builder demonstrating all concepts:
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug)]
pub struct HttpClient {
base_url: String,
headers: HashMap<String, String>,
timeout: Duration,
max_retries: u32,
follow_redirects: bool,
}
pub struct HttpClientBuilder {
base_url: Option<String>,
headers: HashMap<String, String>,
timeout: Duration,
max_retries: u32,
follow_redirects: bool,
}
impl HttpClientBuilder {
pub fn new() -> Self {
Self {
base_url: None,
headers: HashMap::new(),
timeout: Duration::from_secs(30),
max_retries: 3,
follow_redirects: true,
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.timeout = duration;
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
pub fn follow_redirects(mut self, follow: bool) -> Self {
self.follow_redirects = follow;
self
}
pub fn build(self) -> Result<HttpClient, &'static str> {
let base_url = self.base_url
.ok_or("base_url is required")?;
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return Err("base_url must start with http:// or https://");
}
Ok(HttpClient {
base_url,
headers: self.headers,
timeout: self.timeout,
max_retries: self.max_retries,
follow_redirects: self.follow_redirects,
})
}
}
// Usage
let client = HttpClientBuilder::new()
.base_url("https://api.example.com")
.header("Authorization", "Bearer token123")
.header("User-Agent", "MyApp/1.0")
.timeout(Duration::from_secs(10))
.max_retries(5)
.build()
.expect("Failed to build HTTP client");
Best Practices and Conclusion
Use builders when your types have more than three or four fields, or when optional configuration is common. They’re particularly valuable for public APIs where ergonomics matter.
Performance concerns are minimal—builders are zero-cost abstractions when optimizations are enabled. The compiler inlines setter methods and eliminates intermediate allocations.
Consider alternatives for simpler cases. The Default trait with struct update syntax works well for types with mostly optional fields:
let config = ServerConfig {
port: 3000,
..Default::default()
};
For types with few required parameters, a simple constructor function suffices:
impl ServerConfig {
pub fn new(host: String, port: u16) -> Self {
Self {
host,
port,
max_connections: 1000,
// ... other defaults
}
}
}
The builder pattern shines in Rust’s type system. It transforms construction of complex types from error-prone and verbose into safe, readable, and maintainable. Whether hand-rolled or derived, builders are an essential tool for any Rust developer building robust APIs.