Functional Builder Pattern
Tutorial Video
Text description (accessibility)
This video demonstrates the "Functional Builder Pattern" functional Rust example. Difficulty level: Advanced. Key concepts covered: Functional Programming. The builder pattern addresses the "telescoping constructor" problem — many optional fields make constructors unwieldy. Key difference from OCaml: 1. **Consuming vs functional update**: Rust consumes `self` at each step (ownership transfer); OCaml creates a new record at each step (GC
Tutorial
The Problem
The builder pattern addresses the "telescoping constructor" problem — many optional fields make constructors unwieldy. The functional variant uses consuming methods (self -> Self) instead of &mut self, creating an immutable chain. Each method returns a new value with one field changed. This style is prevalent in Rust's standard library (std::thread::Builder) and ecosystem (reqwest::ClientBuilder). It is also related to OCaml's record functional update syntax { record with field = new_val }.
🎯 Learning Outcomes
fn field(mut self, val: T) -> Self enable method chainingDefault provides sensible starting configuration for buildersBuilder and Config typesbuild() that validates the configuration before returning itCode Example
impl Config {
fn host(mut self, h: impl Into<String>) -> Self {
self.host = h.into();
self
}
fn port(mut self, p: u16) -> Self {
self.port = p;
self
}
fn tls(mut self, b: bool) -> Self {
self.tls = b;
self
}
}
let cfg = Config::default()
.host("api.example.com")
.port(443)
.tls(true)
.build()?;Key Differences
self at each step (ownership transfer); OCaml creates a new record at each step (GC-managed copy)..host("x") is a method call; OCaml with_host "x" config is a function call, typically chained with |>.build() can return Result<Config, Error> for validation; OCaml typically validates in a separate check function.Default trait or default record value.OCaml Approach
OCaml uses functional record update syntax directly:
type config = { host: string; port: int; tls: bool; timeout: float }
let default_config = { host = "localhost"; port = 80; tls = false; timeout = 30.0 }
let with_host h c = { c with host = h }
let with_port p c = { c with port = p }
(* Usage: default_config |> with_host "example.com" |> with_port 443 *)
Full Source
#![allow(clippy::all)]
//! # Functional Builder Pattern
//!
//! Builder pattern using consuming methods that return Self for chaining.
/// Network connection configuration.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub host: String,
pub port: u16,
pub timeout: f64,
pub retries: u32,
pub tls: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
host: "localhost".into(),
port: 80,
timeout: 30.0,
retries: 3,
tls: false,
}
}
}
impl Config {
/// Create a new config with defaults.
pub fn new() -> Self {
Self::default()
}
/// Set the host (consuming builder method).
pub fn host(mut self, h: impl Into<String>) -> Self {
self.host = h.into();
self
}
/// Set the port.
pub fn port(mut self, p: u16) -> Self {
self.port = p;
self
}
/// Set the timeout in seconds.
pub fn timeout(mut self, t: f64) -> Self {
self.timeout = t;
self
}
/// Set the retry count.
pub fn retries(mut self, r: u32) -> Self {
self.retries = r;
self
}
/// Enable or disable TLS.
pub fn tls(mut self, b: bool) -> Self {
self.tls = b;
self
}
/// Validate and build the final config.
pub fn build(self) -> Result<Self, String> {
if self.host.is_empty() {
return Err("host required".into());
}
if self.port == 0 {
return Err("port required".into());
}
if self.timeout <= 0.0 {
return Err("timeout must be positive".into());
}
Ok(self)
}
}
/// HTTP request builder.
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
}
impl Request {
pub fn get(url: impl Into<String>) -> Self {
Self {
method: "GET".into(),
url: url.into(),
headers: Vec::new(),
body: None,
}
}
pub fn post(url: impl Into<String>) -> Self {
Self {
method: "POST".into(),
url: url.into(),
headers: Vec::new(),
body: None,
}
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((key.into(), value.into()));
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn json_body(self, body: impl Into<String>) -> Self {
self.header("Content-Type", "application/json").body(body)
}
}
/// Query builder for SQL-like queries.
#[derive(Debug, Clone, Default)]
pub struct Query {
pub table: String,
pub columns: Vec<String>,
pub where_clause: Option<String>,
pub order_by: Option<String>,
pub limit: Option<usize>,
}
impl Query {
pub fn from(table: impl Into<String>) -> Self {
Self {
table: table.into(),
..Default::default()
}
}
pub fn select(mut self, cols: &[&str]) -> Self {
self.columns = cols.iter().map(|s| s.to_string()).collect();
self
}
pub fn where_eq(mut self, clause: impl Into<String>) -> Self {
self.where_clause = Some(clause.into());
self
}
pub fn order_by(mut self, col: impl Into<String>) -> Self {
self.order_by = Some(col.into());
self
}
pub fn limit(mut self, n: usize) -> Self {
self.limit = Some(n);
self
}
pub fn to_sql(&self) -> String {
let cols = if self.columns.is_empty() {
"*".to_string()
} else {
self.columns.join(", ")
};
let mut sql = format!("SELECT {} FROM {}", cols, self.table);
if let Some(ref w) = self.where_clause {
sql.push_str(&format!(" WHERE {}", w));
}
if let Some(ref o) = self.order_by {
sql.push_str(&format!(" ORDER BY {}", o));
}
if let Some(l) = self.limit {
sql.push_str(&format!(" LIMIT {}", l));
}
sql
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let cfg = Config::default()
.host("api.example.com")
.port(443)
.tls(true)
.timeout(60.0)
.build()
.unwrap();
assert_eq!(cfg.host, "api.example.com");
assert_eq!(cfg.port, 443);
assert!(cfg.tls);
}
#[test]
fn test_config_validation_empty_host() {
let result = Config::default().host("").build();
assert!(result.is_err());
}
#[test]
fn test_config_validation_zero_port() {
let result = Config::default().port(0).build();
assert!(result.is_err());
}
#[test]
fn test_config_clone_and_modify() {
let base = Config::default().retries(5);
let dev = base.clone().host("dev.local");
let prod = base.host("prod.example.com").tls(true);
assert_eq!(dev.host, "dev.local");
assert_eq!(prod.host, "prod.example.com");
assert_eq!(dev.retries, 5);
assert_eq!(prod.retries, 5);
}
#[test]
fn test_request_builder() {
let req = Request::get("https://api.example.com/users")
.header("Authorization", "Bearer token")
.header("Accept", "application/json");
assert_eq!(req.method, "GET");
assert_eq!(req.headers.len(), 2);
}
#[test]
fn test_request_json_body() {
let req = Request::post("https://api.example.com/users").json_body(r#"{"name": "Alice"}"#);
assert_eq!(req.method, "POST");
assert!(req
.headers
.iter()
.any(|(k, v)| k == "Content-Type" && v == "application/json"));
assert!(req.body.is_some());
}
#[test]
fn test_query_builder() {
let sql = Query::from("users")
.select(&["id", "name", "email"])
.where_eq("active = true")
.order_by("name")
.limit(10)
.to_sql();
assert_eq!(
sql,
"SELECT id, name, email FROM users WHERE active = true ORDER BY name LIMIT 10"
);
}
#[test]
fn test_query_default_columns() {
let sql = Query::from("users").to_sql();
assert_eq!(sql, "SELECT * FROM users");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let cfg = Config::default()
.host("api.example.com")
.port(443)
.tls(true)
.timeout(60.0)
.build()
.unwrap();
assert_eq!(cfg.host, "api.example.com");
assert_eq!(cfg.port, 443);
assert!(cfg.tls);
}
#[test]
fn test_config_validation_empty_host() {
let result = Config::default().host("").build();
assert!(result.is_err());
}
#[test]
fn test_config_validation_zero_port() {
let result = Config::default().port(0).build();
assert!(result.is_err());
}
#[test]
fn test_config_clone_and_modify() {
let base = Config::default().retries(5);
let dev = base.clone().host("dev.local");
let prod = base.host("prod.example.com").tls(true);
assert_eq!(dev.host, "dev.local");
assert_eq!(prod.host, "prod.example.com");
assert_eq!(dev.retries, 5);
assert_eq!(prod.retries, 5);
}
#[test]
fn test_request_builder() {
let req = Request::get("https://api.example.com/users")
.header("Authorization", "Bearer token")
.header("Accept", "application/json");
assert_eq!(req.method, "GET");
assert_eq!(req.headers.len(), 2);
}
#[test]
fn test_request_json_body() {
let req = Request::post("https://api.example.com/users").json_body(r#"{"name": "Alice"}"#);
assert_eq!(req.method, "POST");
assert!(req
.headers
.iter()
.any(|(k, v)| k == "Content-Type" && v == "application/json"));
assert!(req.body.is_some());
}
#[test]
fn test_query_builder() {
let sql = Query::from("users")
.select(&["id", "name", "email"])
.where_eq("active = true")
.order_by("name")
.limit(10)
.to_sql();
assert_eq!(
sql,
"SELECT id, name, email FROM users WHERE active = true ORDER BY name LIMIT 10"
);
}
#[test]
fn test_query_default_columns() {
let sql = Query::from("users").to_sql();
assert_eq!(sql, "SELECT * FROM users");
}
}
Deep Comparison
OCaml vs Rust: Functional Builder Pattern
Builder via Method Chaining
OCaml (Pipeline with Record Update)
type config = { host: string; port: int; tls: bool; (* ... *) }
let default_config = { host = "localhost"; port = 80; tls = false }
let with_host h c = { c with host = h }
let with_port p c = { c with port = p }
let with_tls b c = { c with tls = b }
let cfg =
default_config
|> with_host "api.example.com"
|> with_port 443
|> with_tls true
Rust (Consuming Builder)
impl Config {
fn host(mut self, h: impl Into<String>) -> Self {
self.host = h.into();
self
}
fn port(mut self, p: u16) -> Self {
self.port = p;
self
}
fn tls(mut self, b: bool) -> Self {
self.tls = b;
self
}
}
let cfg = Config::default()
.host("api.example.com")
.port(443)
.tls(true)
.build()?;
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Mutation | Immutable update { c with } | mut self consumed |
| Chaining | Pipeline \|> | Dot .method() |
| Ownership | GC copies | Move semantics |
| Clone for reuse | Automatic | Explicit .clone() |
| Validation | Separate function | .build() returns Result |
Benefits
Exercises
fn build(self) -> Result<Config, String> that returns Err if host is empty or port is 0.fn with_host(h: &str, c: Config) -> Config that can be chained with .pipe(|c| with_host("x", c)).ConfigBuilder struct with &mut self methods and a build() -> Config that consumes the builder — compare API ergonomics with the consuming-self style.