Cuando una struct tiene muchos campos, algunos opcionales, algunos con valores por defecto y otros que requieren validación, el constructor normal se vuelve difícil de usar. Una función new(nombre, email, edad, activo, rol, ciudad, codigo_postal) con siete argumentos posicionales es propensa a errores: es fácil confundir el orden de dos strings del mismo tipo.
El patrón Builder resuelve esto permitiendo construir el objeto campo a campo, con nombres explícitos y validación centralizada.
Un Builder manual
El Builder más básico es una struct separada que acumula los datos y tiene un método build() que crea el objeto final:
struct Usuario {
nombre: String,
email: String,
edad: u32,
activo: bool,
}
#[derive(Default)]
struct UsuarioBuilder {
nombre: Option<String>,
email: Option<String>,
edad: Option<u32>,
activo: bool,
}
impl UsuarioBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn nombre(mut self, nombre: impl Into<String>) -> Self {
self.nombre = Some(nombre.into());
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
pub fn edad(mut self, edad: u32) -> Self {
self.edad = Some(edad);
self
}
pub fn activo(mut self, activo: bool) -> Self {
self.activo = activo;
self
}
pub fn build(self) -> Result<Usuario, String> {
let nombre = self.nombre.ok_or("nombre es obligatorio")?;
let email = self.email.ok_or("email es obligatorio")?;
let edad = self.edad.ok_or("edad es obligatoria")?;
if !email.contains('@') {
return Err(format!("email inválido: {}", email));
}
if edad > 150 {
return Err(format!("edad inválida: {}", edad));
}
Ok(Usuario { nombre, email, edad, activo: self.activo })
}
}
El uso es fluido y legible:
let usuario = UsuarioBuilder::new()
.nombre("Ana García")
.email("[email protected]")
.edad(30)
.activo(true)
.build()?;
Validación en el Builder
Una ventaja del Builder es que puedes añadir validaciones complejas en el método build() sin contaminar el constructor de la struct. La struct resultante siempre está en un estado válido, porque solo se puede crear a través del Builder:
pub fn build(self) -> Result<Usuario, Vec<String>> {
let mut errores = Vec::new();
let nombre = match self.nombre {
Some(n) if n.len() >= 2 => n,
Some(_) => {
errores.push("El nombre debe tener al menos 2 caracteres".to_string());
String::new()
},
None => {
errores.push("nombre es obligatorio".to_string());
String::new()
}
};
let email = match self.email {
Some(e) if e.contains('@') && e.contains('.') => e,
Some(e) => {
errores.push(format!("email inválido: {}", e));
String::new()
},
None => {
errores.push("email es obligatorio".to_string());
String::new()
}
};
if !errores.is_empty() {
return Err(errores);
}
Ok(Usuario {
nombre,
email,
edad: self.edad.unwrap_or(0),
activo: self.activo,
})
}
derive_builder: generar Builders automáticamente
Para structs con muchos campos, escribir el Builder a mano es tedioso. El crate derive_builder genera el Builder automáticamente a partir de una macro derive:
[dependencies]
derive_builder = "0.13"
use derive_builder::Builder;
#[derive(Builder, Debug)]
#[builder(setter(into))]
struct ConexionDB {
host: String,
puerto: u16,
#[builder(default = ""postgres".to_string()")]
usuario: String,
#[builder(default)]
password: Option<String>,
#[builder(default = ""mi_db".to_string()")]
base_datos: String,
#[builder(default = "10")]
max_conexiones: u32,
}
// Uso generado automáticamente:
let config = ConexionDBBuilder::default()
.host("localhost")
.puerto(5432)
.base_datos("produccion")
.max_conexiones(20)
.build()?;
El atributo setter(into) hace que los setters acepten cualquier tipo que implemente Into<T>, lo que permite pasar &str donde el campo es String. El atributo default define el valor cuando no se llama al setter.
TypedBuilder: verificación en tiempo de compilación
El crate typed-builder lleva el Builder un paso más allá: usa el sistema de tipos de Rust para garantizar en compilación que se han establecido todos los campos obligatorios:
[dependencies]
typed-builder = "0.18"
use typed_builder::TypedBuilder;
#[derive(TypedBuilder, Debug)]
struct Peticion {
#[builder(setter(into))]
url: String,
#[builder(default = "GET".to_string(), setter(into))]
metodo: String,
#[builder(default, setter(strip_option))]
cuerpo: Option<String>,
#[builder(default = 30)]
timeout_segundos: u64,
}
// OK: url es obligatorio y se proporcionó
let peticion = Peticion::builder()
.url("https://api.ejemplo.com/datos")
.metodo("POST")
.cuerpo("{'clave': 'valor'}")
.build();
// ERROR en compilación: falta url
// let peticion = Peticion::builder().metodo("GET").build();
Con typed-builder, intentar llamar a build() sin haber establecido los campos obligatorios es un error de compilación, no de ejecución. No necesitas devolver Result: si compila, el objeto es válido.
El patrón Builder con estado de tipo
Una variante avanzada combina el Builder con el type-state pattern para garantizar un orden específico de construcción:
struct Vacío;
struct ConNombre(String);
struct ConEmail(String, String);
struct RegistroBuilder<Estado> {
estado: Estado,
}
impl RegistroBuilder<Vacío> {
pub fn new() -> Self {
RegistroBuilder { estado: Vacío }
}
pub fn nombre(self, nombre: String) -> RegistroBuilder<ConNombre> {
RegistroBuilder { estado: ConNombre(nombre) }
}
}
impl RegistroBuilder<ConNombre> {
pub fn email(self, email: String) -> RegistroBuilder<ConEmail> {
let ConNombre(nombre) = self.estado;
RegistroBuilder { estado: ConEmail(nombre, email) }
}
}
impl RegistroBuilder<ConEmail> {
pub fn build(self) -> Usuario {
let ConEmail(nombre, email) = self.estado;
Usuario { nombre, email, edad: 0, activo: false }
}
}
// El compilador exige el orden: nombre ? email ? build
let usuario = RegistroBuilder::new()
.nombre("Luis".to_string())
.email("[email protected]".to_string())
.build();
Cuándo usar cada variante
El Builder manual es la mejor opción cuando necesitas validaciones específicas o cuando quieres controlar exactamente qué sucede en build(). Es código explícito y sin dependencias extra.
derive_builder es ideal para structs de configuración con muchos campos opcionales. Genera mucho código repetitivo automáticamente y se integra bien con serde.
typed-builder es la mejor opción cuando quieres garantías en compilación de que todos los campos obligatorios están presentes. El coste es que los errores de compilación cuando falta un campo pueden ser mensajes largos.
El Builder con type-state es la variante más poderosa pero también la más compleja. Úsalo cuando el orden de configuración importa semánticamente o cuando construir el objeto en el orden equivocado sería un error lógico.
