Fluent APIs and compile-time validation
The Builder pattern separates the construction of a complex object from its representation. In Rust, it's particularly powerful because we can use the type system to enforce that required fields are set before building.
Builders solve several issues:
use std::time::Duration;
/// The target struct with many fields
#[derive(Debug)]
pub struct HttpClient {
base_url: String,
timeout: Duration,
max_retries: u32,
user_agent: String,
headers: Vec<(String, String)>,
follow_redirects: bool,
}
/// Simple builder (all fields optional with defaults)
#[derive(Default)]
pub struct HttpClientBuilder {
base_url: Option<String>,
timeout: Option<Duration>,
max_retries: Option<u32>,
user_agent: Option<String>,
headers: Vec<(String, String)>,
follow_redirects: Option<bool>,
}
impl HttpClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = Some(retries);
self
}
pub fn user_agent(mut self, agent: impl Into<String>) -> Self {
self.user_agent = Some(agent.into());
self
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((key.into(), value.into()));
self
}
pub fn follow_redirects(mut self, follow: bool) -> Self {
self.follow_redirects = Some(follow);
self
}
pub fn build(self) -> Result<HttpClient, &'static str> {
let base_url = self.base_url.ok_or("base_url is required")?;
Ok(HttpClient {
base_url,
timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
max_retries: self.max_retries.unwrap_or(3),
user_agent: self.user_agent.unwrap_or_else(|| "RustClient/1.0".to_string()),
headers: self.headers,
follow_redirects: self.follow_redirects.unwrap_or(true),
})
}
}
/// Type-state builder: compile-time validation
#[derive(Debug)]
pub struct Server {
host: String,
port: u16,
tls: bool,
workers: usize,
}
// Marker types for type-state
pub struct NoHost;
pub struct HasHost;
pub struct NoPort;
pub struct HasPort;
/// Type-state builder that requires host and port
pub struct ServerBuilder<H, P> {
host: Option<String>,
port: Option<u16>,
tls: bool,
workers: usize,
_host_state: std::marker::PhantomData<H>,
_port_state: std::marker::PhantomData<P>,
}
impl ServerBuilder<NoHost, NoPort> {
pub fn new() -> Self {
ServerBuilder {
host: None,
port: None,
tls: false,
workers: num_cpus::get().unwrap_or(4),
_host_state: std::marker::PhantomData,
_port_state: std::marker::PhantomData,
}
}
}
impl<P> ServerBuilder<NoHost, P> {
pub fn host(self, host: impl Into<String>) -> ServerBuilder<HasHost, P> {
ServerBuilder {
host: Some(host.into()),
port: self.port,
tls: self.tls,
workers: self.workers,
_host_state: std::marker::PhantomData,
_port_state: std::marker::PhantomData,
}
}
}
impl<H> ServerBuilder<H, NoPort> {
pub fn port(self, port: u16) -> ServerBuilder<H, HasPort> {
ServerBuilder {
host: self.host,
port: Some(port),
tls: self.tls,
workers: self.workers,
_host_state: std::marker::PhantomData,
_port_state: std::marker::PhantomData,
}
}
}
impl<H, P> ServerBuilder<H, P> {
pub fn tls(mut self, enabled: bool) -> Self {
self.tls = enabled;
self
}
pub fn workers(mut self, count: usize) -> Self {
self.workers = count;
self
}
}
// Only implement build() when both host and port are set
impl ServerBuilder<HasHost, HasPort> {
pub fn build(self) -> Server {
Server {
host: self.host.unwrap(),
port: self.port.unwrap(),
tls: self.tls,
workers: self.workers,
}
}
}
/// Builder with derive macro (using typed-builder crate pattern)
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
pub host: String,
pub port: u16,
pub database: String,
pub user: String,
pub password: Option<String>,
pub max_connections: u32,
pub connect_timeout: Duration,
}
/// Manual implementation of what typed-builder generates
pub struct DatabaseConfigBuilder {
host: String,
port: Option<u16>,
database: String,
user: Option<String>,
password: Option<String>,
max_connections: Option<u32>,
connect_timeout: Option<Duration>,
}
impl DatabaseConfigBuilder {
pub fn new(host: impl Into<String>, database: impl Into<String>) -> Self {
DatabaseConfigBuilder {
host: host.into(),
port: None,
database: database.into(),
user: None,
password: None,
max_connections: None,
connect_timeout: None,
}
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn max_connections(mut self, max: u32) -> Self {
self.max_connections = Some(max);
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
pub fn build(self) -> DatabaseConfig {
DatabaseConfig {
host: self.host,
port: self.port.unwrap_or(5432),
database: self.database,
user: self.user.unwrap_or_else(|| "postgres".to_string()),
password: self.password,
max_connections: self.max_connections.unwrap_or(10),
connect_timeout: self.connect_timeout.unwrap_or(Duration::from_secs(5)),
}
}
}
// Helper for simulating num_cpus
mod num_cpus {
pub fn get() -> Option<usize> {
Some(8)
}
}
fn main() {
// Simple builder
let client = HttpClientBuilder::new()
.base_url("https://api.example.com")
.timeout(Duration::from_secs(60))
.header("Authorization", "Bearer token123")
.header("Accept", "application/json")
.max_retries(5)
.build()
.unwrap();
println!("HTTP Client: {:?}", client);
// Type-state builder (compile-time safety)
let server = ServerBuilder::new()
.host("localhost")
.port(8080)
.tls(true)
.workers(4)
.build();
println!("Server: {:?}", server);
// This won't compile - missing host!
// let invalid = ServerBuilder::new()
// .port(8080)
// .build();
// Database config builder
let db_config = DatabaseConfigBuilder::new("localhost", "myapp")
.port(5432)
.user("admin")
.password("secret")
.max_connections(20)
.build();
println!("Database: {:?}", db_config);
}
self, enabling fluent APIimpl Into: Accept &str, String, etc. without explicit conversionunwrap_or provides sensible defaults| Pattern | Validation | Compile-time Safety | Complexity |
|---------|------------|---------------------|------------|
| Simple Builder | Runtime (Result) | No | Low |
| Type-state Builder | Compile-time | Yes | High |
| Derive macro (typed-builder) | Compile-time | Yes | Low |
// DON'T: Builder for simple types
struct PointBuilder { x: i32, y: i32 }
impl PointBuilder {
fn build(self) -> Point { Point { x: self.x, y: self.y } }
}
// Just use: Point { x: 1, y: 2 }
// DON'T: Mutable builder that can be reused incorrectly
let mut builder = Builder::new();
builder.name("first");
let first = builder.build();
// builder still has state from first!
let second = builder.build(); // Accidentally reuses state
// DO: Consume builder on build
fn build(self) -> Product { /* consumes self */ }
ClientBuilder for HTTP client configurationRuntime::Builder for async runtime setupJsonBuilder for constructing JSONHttpClientBuilder (e.g., URL format, positive timeout)with_defaults() method that pre-populates common configurationsRun this code in the official Rust Playground