Rust Typed Builder Pattern: 5 Core Patterns for Compile-Time Safe Object Construction
In the world of Rust, there's no null, no default constructors, no optional parameters—building a complex object means either writing a screenful of Foo { a: 1, b: 2, ... } or using the Builder pattern. But traditional Builders have a fatal flaw: forgot to call .name("xxx")? No compile error—runtime panic. In 2026, Rust's type system gives us a better answer—use Type State to guarantee required fields aren't missing at compile time, use the typed-builder crate to eliminate boilerplate, and use generic Builders for conditional requirements. Today, let's dive deep into 5 core patterns for Rust typed Builder, from manual Builder to type-state, from typed-builder to production-grade composition.
Core Concepts at a Glance
| Concept | Description | Key Technique |
|---|---|---|
| Builder Pattern | Step-by-step construction of complex objects | struct FooBuilder + build() |
| Type State | Track field status with generic parameters | Builder<Missing, Missing> |
| typed-builder | Crate for auto-deriving type-safe Builders | #[derive(TypedBuilder)] |
| Generic Builder | Express conditional required fields with generics | Builder<NameSet = Set> |
| Validation Builder | Execute business rule validation at build time | fn build() -> Result<Foo, Error> |
Problem Analysis: 5 Pain Points
- Missing Required Fields: Traditional Builder's
build()returns Option or panics—you don't discover missing fields until runtime - Boilerplate Explosion: 10 fields mean 10 setter methods, 10 Option fields, and one massive
build()function - Can't Express Conditional Requirements: If field A is set, field B must also be set; if A isn't set, B is optional—traditional Builders can't express this at compile time
- Construction Order Constraints: Some fields must be set before others—traditional Builders can't enforce order
- Scattered Validation Logic: Field validation scattered across setters, then validated again at build time—easy to miss
Pattern 1: Manual Builder with Type State Pattern
Use generic parameters to track each field's status, guaranteeing required fields aren't missing at compile time.
use std::marker::PhantomData;
/// Field not set
pub struct Missing;
/// Field set
pub struct Set<T>(PhantomData<T>);
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub max_connections: u32,
pub timeout_secs: u64,
pub enable_tls: bool,
pub tls_cert_path: Option<String>,
}
pub struct ServerConfigBuilder<HostSet, PortSet> {
host: Option<String>,
port: Option<u16>,
max_connections: u32,
timeout_secs: u64,
enable_tls: bool,
tls_cert_path: Option<String>,
_host: PhantomData<HostSet>,
_port: PhantomData<PortSet>,
}
impl ServerConfigBuilder<Missing, Missing> {
pub fn new() -> Self {
Self {
host: None,
port: None,
max_connections: 1000,
timeout_secs: 30,
enable_tls: false,
tls_cert_path: None,
_host: PhantomData,
_port: PhantomData,
}
}
}
impl<PortSet> ServerConfigBuilder<Missing, PortSet> {
pub fn host(self, host: impl Into<String>) -> ServerConfigBuilder<Set<String>, PortSet> {
ServerConfigBuilder {
host: Some(host.into()),
port: self.port,
max_connections: self.max_connections,
timeout_secs: self.timeout_secs,
enable_tls: self.enable_tls,
tls_cert_path: self.tls_cert_path,
_host: PhantomData,
_port: self._port,
}
}
}
impl<HostSet> ServerConfigBuilder<HostSet, Missing> {
pub fn port(self, port: u16) -> ServerConfigBuilder<HostSet, Set<u16>> {
ServerConfigBuilder {
host: self.host,
port: Some(port),
max_connections: self.max_connections,
timeout_secs: self.timeout_secs,
enable_tls: self.enable_tls,
tls_cert_path: self.tls_cert_path,
_host: self._host,
_port: PhantomData,
}
}
}
impl<HostSet, PortSet> ServerConfigBuilder<HostSet, PortSet> {
pub fn max_connections(mut self, max: u32) -> Self {
self.max_connections = max;
self
}
pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
pub fn enable_tls(mut self, enable: bool) -> Self {
self.enable_tls = enable;
self
}
pub fn tls_cert_path(mut self, path: impl Into<String>) -> Self {
self.tls_cert_path = Some(path.into());
self
}
}
// build() is only available when both Host and Port are Set
impl ServerConfigBuilder<Set<String>, Set<u16>> {
pub fn build(self) -> ServerConfig {
ServerConfig {
host: self.host.unwrap(),
port: self.port.unwrap(),
max_connections: self.max_connections,
timeout_secs: self.timeout_secs,
enable_tls: self.enable_tls,
tls_cert_path: self.tls_cert_path,
}
}
}
fn main() {
// ✅ Compiles: all required fields are set
let config = ServerConfigBuilder::new()
.host("0.0.0.0")
.port(8080)
.max_connections(5000)
.timeout_secs(60)
.enable_tls(true)
.tls_cert_path("/etc/ssl/cert.pem")
.build();
println!("Server config: {:?}", config);
// ❌ Compile error: missing port field
// ServerConfigBuilder::new()
// .host("0.0.0.0")
// .build();
// error[E0599]: no method named `build` found for struct `ServerConfigBuilder<Set<String>, Missing>`
}
Key Takeaways:
MissingandSet<T>are zero-sized types (ZST), zero runtime overheadbuild()is only available in theSet<String>, Set<u16>state, guaranteeing required fields at compile time- Optional fields can be set in any state without changing the type state
- Field setting order doesn't matter—host then port or port then host both work
Pattern 2: typed-builder Crate Auto-Derivation
Writing type-state Builders manually is tedious? The typed-builder crate does it with one macro.
use typed_builder::TypedBuilder;
use std::time::Duration;
#[derive(Debug, Clone, TypedBuilder)]
pub struct DatabaseConfig {
#[builder(default_code = r#""localhost".into()"#)]
pub host: String,
#[builder(default = 5432)]
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
#[builder(default = 100)]
pub max_connections: u32,
#[builder(default = 30)]
pub timeout_secs: u64,
#[builder(default = false)]
pub enable_ssl: bool,
}
#[derive(Debug, Clone, TypedBuilder)]
pub struct HttpClientConfig {
pub base_url: String,
#[builder(default = Duration::from_secs(30), setter(into))]
pub timeout: Duration,
#[builder(default = 3)]
pub max_retries: u32,
#[builder(default, setter(transform = |pairs: Vec<(&str, &str)>| {
let mut map = reqwest::header::HeaderMap::new();
for (k, v) in pairs {
if let (Ok(name), Ok(val)) = (
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
reqwest::header::HeaderValue::from_str(v),
) {
map.insert(name, val);
}
}
map
}))]
pub headers: reqwest::header::HeaderMap,
}
fn main() {
let db_config = DatabaseConfig::builder()
.database("toolsku_prod")
.username("admin")
.password("secret123")
.max_connections(200)
.build();
println!("Database config: {:?}", db_config);
// ❌ Compile error: missing required fields
// DatabaseConfig::builder()
// .database("toolsku_prod")
// .build();
let http_config = HttpClientConfig::builder()
.base_url("https://api.toolsku.com")
.timeout(Duration::from_secs(60))
.headers(vec![("Authorization", "Bearer token123")])
.build();
println!("HTTP client config: {:?}", http_config);
}
Key Takeaways:
#[builder(default = ...)]marks optional fields; required fields don't have default#[builder(setter(into))]makes setters acceptimpl Into<T>, more flexible#[builder(setter(transform = ...))]customizes setter logic, like type conversion#[builder(default_code = ...)]uses code expressions as default values
Pattern 3: Generic Builder with Conditional Required Fields
Some fields are "conditionally required"—when A is set, B must also be set; otherwise B is optional. Express this precisely with generic parameters.
use std::marker::PhantomData;
pub struct TlsEnabled;
pub struct TlsDisabled;
#[derive(Debug)]
pub struct SecureServerConfig {
pub host: String,
pub port: u16,
pub enable_tls: bool,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
}
pub struct SecureServerBuilder<TlsState> {
host: Option<String>,
port: Option<u16>,
enable_tls: bool,
tls_cert: Option<String>,
tls_key: Option<String>,
_tls: PhantomData<TlsState>,
}
impl SecureServerBuilder<TlsDisabled> {
pub fn new() -> Self {
Self {
host: None, port: None, enable_tls: false,
tls_cert: None, tls_key: None, _tls: PhantomData,
}
}
pub fn enable_tls(self) -> SecureServerBuilder<TlsEnabled> {
SecureServerBuilder {
host: self.host, port: self.port, enable_tls: true,
tls_cert: self.tls_cert, tls_key: self.tls_key, _tls: PhantomData,
}
}
}
impl SecureServerBuilder<TlsEnabled> {
pub fn tls_cert(mut self, cert: impl Into<String>) -> Self {
self.tls_cert = Some(cert.into()); self
}
pub fn tls_key(mut self, key: impl Into<String>) -> Self {
self.tls_key = Some(key.into()); self
}
}
impl<TlsState> SecureServerBuilder<TlsState> {
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into()); self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port); self
}
}
impl SecureServerBuilder<TlsDisabled> {
pub fn build(self) -> Result<SecureServerConfig, String> {
let host = self.host.ok_or("host is required")?;
let port = self.port.ok_or("port is required")?;
Ok(SecureServerConfig { host, port, enable_tls: false, tls_cert: None, tls_key: None })
}
}
impl SecureServerBuilder<TlsEnabled> {
pub fn build(self) -> Result<SecureServerConfig, String> {
let host = self.host.ok_or("host is required")?;
let port = self.port.ok_or("port is required")?;
let tls_cert = self.tls_cert.ok_or("tls_cert is required when TLS is enabled")?;
let tls_key = self.tls_key.ok_or("tls_key is required when TLS is enabled")?;
Ok(SecureServerConfig { host, port, enable_tls: true, tls_cert: Some(tls_cert), tls_key: Some(tls_key) })
}
}
fn main() -> Result<(), String> {
let config_no_tls = SecureServerBuilder::new()
.host("0.0.0.0")
.port(8080)
.build()?;
let config_with_tls = SecureServerBuilder::new()
.host("0.0.0.0")
.port(443)
.enable_tls()
.tls_cert("/etc/ssl/cert.pem")
.tls_key("/etc/ssl/key.pem")
.build()?;
Ok(())
}
Key Takeaways:
TlsEnabled/TlsDisabledmark TLS state; different states have differentbuild()implementations- Enabling TLS triggers a state transition—the compiler "knows" you enabled TLS
- Conditional required field validation happens in
build(), with clearer error messages
Pattern 4: Validation Builder with Build-Time Checks
Business rule validation shouldn't be scattered across setters—it should be executed uniformly at build() time.
use std::collections::HashMap;
#[derive(Debug)]
pub enum ValidationError {
FieldRequired { field: String, reason: String },
FieldRange { field: String, min: String, max: String, actual: String },
Custom(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FieldRequired { field, reason } => write!(f, "Field '{}' is required: {}", field, reason),
Self::FieldRange { field, min, max, actual } => {
write!(f, "Field '{}' out of range: expected {}~{}, got {}", field, min, max, actual)
}
Self::Custom(msg) => write!(f, "{}", msg),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum EvictionPolicy { Lru, Lfu, Fifo, Ttl }
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub max_size_mb: u64,
pub ttl_secs: u64,
pub eviction_policy: EvictionPolicy,
pub namespace: String,
pub enable_compression: bool,
pub compression_level: u32,
pub tags: HashMap<String, String>,
}
pub struct CacheConfigBuilder {
max_size_mb: Option<u64>,
ttl_secs: Option<u64>,
eviction_policy: Option<EvictionPolicy>,
namespace: Option<String>,
enable_compression: Option<bool>,
compression_level: Option<u32>,
tags: HashMap<String, String>,
}
impl CacheConfigBuilder {
pub fn new() -> Self {
Self {
max_size_mb: None, ttl_secs: None, eviction_policy: None,
namespace: None, enable_compression: None, compression_level: None,
tags: HashMap::new(),
}
}
pub fn max_size_mb(mut self, size: u64) -> Self { self.max_size_mb = Some(size); self }
pub fn ttl_secs(mut self, secs: u64) -> Self { self.ttl_secs = Some(secs); self }
pub fn eviction_policy(mut self, policy: EvictionPolicy) -> Self { self.eviction_policy = Some(policy); self }
pub fn namespace(mut self, ns: impl Into<String>) -> Self { self.namespace = Some(ns.into()); self }
pub fn enable_compression(mut self, enable: bool) -> Self { self.enable_compression = Some(enable); self }
pub fn compression_level(mut self, level: u32) -> Self { self.compression_level = Some(level); self }
pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.tags.insert(key.into(), value.into()); self
}
pub fn build(self) -> Result<CacheConfig, Vec<ValidationError>> {
let mut errors = Vec::new();
let max_size_mb = match self.max_size_mb {
Some(v) if v > 0 => v,
Some(v) => { errors.push(ValidationError::FieldRange { field: "max_size_mb".into(), min: "1".into(), max: "unlimited".into(), actual: v.to_string() }); v }
None => { errors.push(ValidationError::FieldRequired { field: "max_size_mb".into(), reason: "Cache must have a maximum size".into() }); 0 }
};
let ttl_secs = match self.ttl_secs {
Some(v) if v > 0 => v,
Some(v) => { errors.push(ValidationError::FieldRange { field: "ttl_secs".into(), min: "1".into(), max: "unlimited".into(), actual: v.to_string() }); v }
None => { errors.push(ValidationError::FieldRequired { field: "ttl_secs".into(), reason: "Cache must have a TTL".into() }); 0 }
};
let enable_compression = self.enable_compression.unwrap_or(false);
if enable_compression && self.compression_level.is_none() {
errors.push(ValidationError::FieldRequired { field: "compression_level".into(), reason: "Compression level required when compression is enabled".into() });
}
if !errors.is_empty() { return Err(errors); }
Ok(CacheConfig {
max_size_mb, ttl_secs,
eviction_policy: self.eviction_policy.unwrap_or(EvictionPolicy::Lru),
namespace: self.namespace.unwrap_or_else(|| "default".into()),
enable_compression,
compression_level: self.compression_level.unwrap_or(6),
tags: self.tags,
})
}
}
fn main() -> Result<(), Vec<ValidationError>> {
let config = CacheConfigBuilder::new()
.max_size_mb(1024)
.ttl_secs(300)
.eviction_policy(EvictionPolicy::Lru)
.namespace("toolsku_cache")
.enable_compression(true)
.compression_level(6)
.tag("env", "production")
.build()?;
println!("Cache config: {:?}", config);
Ok(())
}
Key Takeaways:
- Collect all validation errors and return them at once, rather than stopping at the first one
- Validation logic is centralized in
build(), keeping setters pure - Conditional validation (like "compression level required when compression is enabled") is handled uniformly in build
- Returns
Result<T, Vec<ValidationError>>, making it easy for UI layers to display all errors
Pattern 5: Production-Grade Builder Composition
In real projects, multiple Builders need to work together. Use composition to build complex configurations.
use typed_builder::TypedBuilder;
use std::collections::HashMap;
#[derive(Debug, Clone, TypedBuilder)]
pub struct NetworkConfig {
#[builder(default_code = r#""0.0.0.0".into()"#)]
pub bind_address: String,
#[builder(default = 8080)]
pub port: u16,
#[builder(default = 100)]
pub max_connections: u32,
#[builder(default = 30)]
pub timeout_secs: u64,
#[builder(default = true)]
pub enable_keepalive: bool,
}
#[derive(Debug, Clone, TypedBuilder)]
pub struct StorageConfig {
pub backend: StorageBackend,
#[builder(default = 1024)]
pub max_size_mb: u64,
#[builder(default = 300)]
pub ttl_secs: u64,
#[builder(default = 3)]
pub replication_factor: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StorageBackend { Redis, Memcached, InMemory }
#[derive(Debug, Clone, TypedBuilder)]
pub struct SecurityConfig {
#[builder(default = true)]
pub enable_auth: bool,
#[builder(default_code = r#"Some("HS256".into())"#)]
pub jwt_algorithm: Option<String>,
#[builder(default)]
pub allowed_origins: Vec<String>,
#[builder(default = 3600)]
pub token_expiry_secs: u64,
}
#[derive(Debug, Clone, TypedBuilder)]
pub struct LoggingConfig {
#[builder(default_code = r#""info".into()"#)]
pub level: String,
#[builder(default = true)]
pub enable_json_format: bool,
#[builder(default_code = r#"Some("stdout".into())"#)]
pub output_path: Option<String>,
#[builder(default)]
pub extra_fields: HashMap<String, String>,
}
#[derive(Debug, Clone, TypedBuilder)]
pub struct AppConfig {
pub app_name: String,
#[builder(default_code = r#""1.0.0".into()"#)]
pub version: String,
pub network: NetworkConfig,
pub storage: StorageConfig,
pub security: SecurityConfig,
pub logging: LoggingConfig,
}
impl AppConfig {
pub fn development(app_name: impl Into<String>) -> AppConfigBuilder {
AppConfig::builder()
.app_name(app_name)
.network(NetworkConfig::builder().bind_address("127.0.0.1").port(3000).max_connections(10).build())
.storage(StorageConfig::builder().backend(StorageBackend::InMemory).max_size_mb(128).build())
.security(SecurityConfig::builder().enable_auth(false).build())
.logging(LoggingConfig::builder().level("debug").enable_json_format(false).build())
}
pub fn production(app_name: impl Into<String>) -> AppConfigBuilder {
AppConfig::builder()
.app_name(app_name)
.network(NetworkConfig::builder().bind_address("0.0.0.0").port(8080).max_connections(10000).timeout_secs(60).build())
.storage(StorageConfig::builder().backend(StorageBackend::Redis).max_size_mb(4096).replication_factor(3).build())
.security(SecurityConfig::builder().enable_auth(true).jwt_algorithm("RS256").allowed_origins(vec!["https://toolsku.com".into()]).token_expiry_secs(7200).build())
.logging(LoggingConfig::builder().level("info").enable_json_format(true).output_path("/var/log/toolsku/app.log").build())
}
}
fn main() {
let dev_config = AppConfig::development("toolsku-dev").version("0.1.0").build();
println!("Dev config: {:?}", dev_config);
let prod_config = AppConfig::production("toolsku").version("2.5.0").build();
println!("Prod config: {:?}", prod_config);
}
Key Takeaways:
- Each sub-configuration has its own independent Builder with clear responsibilities
AppConfigis built by composing sub-Builders, with clear hierarchy- Preset methods (
development()/production()) provide out-of-the-box configurations - Presets return
AppConfigBuilder, allowing further customization to override preset values
Pitfall Guide
Pitfall 1: Forgetting PhantomData in Type-State Builder
// ❌ Wrong: Generic parameter not used
struct Builder<HostSet> {
host: Option<String>,
// error[E0392]: parameter `HostSet` is never used
}
// ✅ Correct: Use PhantomData to mark generic parameter
struct Builder<HostSet> {
host: Option<String>,
_host: PhantomData<HostSet>,
}
Pitfall 2: typed-builder Default Value Type Mismatch
// ❌ Wrong: default value type doesn't match field type
#[derive(TypedBuilder)]
struct Config {
#[builder(default = "localhost")] // &str can't be assigned to String
pub host: String,
}
// ✅ Correct: Use default_code
#[derive(TypedBuilder)]
struct Config {
#[builder(default_code = r#""localhost".into()"#)]
pub host: String,
}
Pitfall 3: Builder Not Send/Sync
// ❌ Wrong: PhantomData<*const ()> is not Send/Sync
struct Builder<HostSet> {
_host: PhantomData<*const ()>,
}
// ✅ Correct: Use PhantomData<fn() -> HostSet>
struct Builder<HostSet> {
_host: PhantomData<fn() -> HostSet>, // fn() -> T is Send+Sync
}
Pitfall 4: Losing Fields During Type State Transition
// ❌ Wrong: Forgot to pass all fields during transition
impl Builder<Missing> {
fn host(self, host: String) -> Builder<Set<String>> {
Builder {
host: Some(host),
_host: PhantomData,
// port field lost!
}
}
}
// ✅ Correct: Ensure all fields are passed
impl Builder<Missing> {
fn host(self, host: String) -> Builder<Set<String>> {
Builder {
host: Some(host),
port: self.port, // Pass port
_host: PhantomData,
}
}
}
Pitfall 5: Validation Builder Returning Single Error
// ❌ Wrong: Only returns first error
fn build(self) -> Result<Config, ValidationError> {
let host = self.host.ok_or(ValidationError::FieldRequired("host"))?;
let port = self.port.ok_or(ValidationError::FieldRequired("port"))?;
Ok(Config { host, port })
}
// ✅ Correct: Collect all errors and return at once
fn build(self) -> Result<Config, Vec<ValidationError>> {
let mut errors = Vec::new();
// ... collect all errors ...
if !errors.is_empty() { return Err(errors); }
Ok(Config { /* ... */ })
}
Error Troubleshooting Table
| Error Symptom | Possible Cause | Troubleshooting Method | Solution |
|---|---|---|---|
| "no method named build" | Required field not set | Check Builder's generic state | Ensure all required field setters are called |
| E0392 parameter never used | PhantomData missing | Check if generic parameter is used | Add PhantomData<T> field |
| Type mismatch | Default value type wrong | Check #[builder(default = ...)] |
Use default_code or correct type |
| Builder not Send | PhantomData uses raw pointer | Check PhantomData's type parameter | Use PhantomData<fn() -> T> |
| Field value lost | Type state transition missing field | Check field passing in impl blocks | Ensure all fields are assigned in transitions |
| Long compile time | Too many generic parameters | Check Builder's generic count | Use typed-builder to reduce manual generics |
| Setter not chainable | Setter returns Self but uses mut | Check setter signature | Ensure returning Self not &mut Self |
| Circular dependency | A's build needs B, B's build needs A | Check dependencies between Builders | Split into independent Builders |
| Duplicate validation | Validation in both setter and build | Check validation code location | Only validate in build |
| Defaults not taking effect | Forgot #[builder(default)] |
Check optional field annotations | Add default for fields with default values |
Advanced Optimization
-
Macro-Simplified Type State: Write a
declare_builder!macro that auto-generates type-state Builder boilerplate code -
Builder Serialization: Make Builder support
serde::Serializeto save half-built configs to files -
Environment Variable Integration: Builder setters auto-read environment variables as defaults, like
.port_from_env("APP_PORT") -
Config Hot Update: Config objects built by Builder support a
merge()method for runtime config merging without rebuilding -
Compile-Time Field Count: Use const generics to track the number of set fields, guaranteeing at least N fields are set at compile time
Comparison Table
| Approach | Compile-Time Safety | Boilerplate | Flexibility | Use Case |
|---|---|---|---|---|
| Traditional Builder | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Simple objects |
| Type-State Builder | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Strict required fields |
| typed-builder | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ | Most projects |
| Validation Builder | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Complex business rules |
| Composition Builder | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | Multi-module configs |
Summary
Rust's type system evolves the Builder pattern from "runtime praying" to "compile-time guarantee"—type state tracks field status at compile time, typed-builder eliminates boilerplate, generic Builders express conditional requirements, validation Builders unify business rules, and composition Builders build complex configs. Remember: the essence of Rust Builder isn't "method chaining"—it's "making illegal states unrepresentable." If a config can be built, it must be valid.
Online Tools Recommendation
- JSON Formatter — Format Builder output JSON for quick structure validation
- cURL to Code — Convert API requests to Rust Builder code
- Hash Calculator — Calculate config file hashes to ensure build consistency
Try these browser-local tools — no sign-up required →