Type Safety and API Design
Type-driven API design ("parse, don't validate") uses Rust's type system to turn runtime preconditions and boolean flags into compile-time types so callers are guided toward correct usage by their editor and the compiler.
1. Replace Boolean Parameters with Descriptive Enums
A function signature like fn connect(host: &str, verify_tls: bool, keep_alive: bool) is error-prone at call sites (connect("example.com", true, false)—which bool is which?). Replacing boolean flags with two-variant domain enums makes call sites self-documenting and impossible to transpose:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsPolicy {
VerifyCertificates,
DangerAcceptInvalidCerts,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeepAlive {
Enabled,
Disabled,
}
pub fn connect(host: &str, tls: TlsPolicy, keep_alive: KeepAlive) {
println!("Connecting to {host} (tls={tls:?}, keep_alive={keep_alive:?})");
}
fn main() {
connect(
"db.internal",
TlsPolicy::VerifyCertificates,
KeepAlive::Enabled,
);
}
Connecting to db.internal (tls=VerifyCertificates, keep_alive=Enabled)
2. "Parse, Don't Validate": Return Proof Types Instead of bool
A validation function fn is_valid_username(s: &str) -> bool checks an input and immediately throws away the proof in the type system, forcing downstream functions to either trust callers blindly or re-validate s: &str.
Instead, parse raw input into a validated newtype (Username) whose constructor is the only way to create an instance:
mod auth {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Username(String); // Private inner field!
impl Username {
pub fn parse(raw: &str) -> Result<Self, &'static str> {
let trimmed = raw.trim();
if (3..=20).contains(&trimmed.len())
&& trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
Ok(Self(trimmed.to_owned()))
} else {
Err("username must be 3..=20 ASCII alphanumeric/underscore characters")
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
}
// Downstream functions accept `auth::Username` and NEVER need to re-validate it:
fn create_account(user: &auth::Username) {
println!("Created account for verified user '{}'", user.as_str());
}
fn main() {
let valid = auth::Username::parse("prime_dev").unwrap();
create_account(&valid);
let bad = auth::Username::parse("a!");
println!("Rejected bad username: {bad:?}");
}
Created account for verified user 'prime_dev'
Rejected bad username: Err("username must be 3..=20 ASCII alphanumeric/underscore characters")
3. Non-Zero and Non-Empty Types (NonZero<T>)
When 0 is not a valid domain value (such as page sizes, worker thread counts, divisor arguments, or database IDs), use std::num::NonZeroUsize (or NonZeroU32 / NonZeroU64) in your API signature. This both eliminates divide-by-zero / zero-step bugs at compile time and enables the niche optimization so Option<NonZeroU64> occupies just 8 bytes.
Summary of Type-Safe API Idioms
| Weakly Typed Anti-Pattern | Type-Safe Replacement |
|---|---|
fn transfer(from: u64, to: u64, cents: u64) |
fn transfer(from: AccountId, to: AccountId, amount: UsdCents) |
fn render(visible: bool, cached: bool) |
fn render(vis: Visibility, cache: CachePolicy) |
fn set_workers(n: usize) (panics if n == 0) |
fn set_workers(n: NonZeroUsize) |
struct Response { status: u16, body: Option<String>, err: Option<String> } |
enum Response { Ok(String), Err { status: u16, message: String } } |
#[must_use] omitted on a builder/guard return type |
Annotate with #[must_use] so ignored return values trigger a compiler warning |