Design domain types to make illegal states unrepresentable. Use enums/variants (sum types) instead of strings or booleans when a value has a fixed set of meaningful states.
// ❌ WRONG: Any string is accepted, typos compile fine
struct Post {
status: String, // "draft", "published", "archived"... or "publsihed"?
}
fn publish(post: &mut Post) {
post.status = "published".to_string();
}
// ✅ CORRECT: Only valid states exist
enum PostStatus {
Draft,
Published,
Archived,
}
struct Post {
status: PostStatus,
}
fn publish(post: &mut Post) {
post.status = PostStatus::Published;
}// ❌ WRONG: 4 combinations exist, but only 3 are valid
struct User {
is_verified: bool,
is_banned: bool,
// What does is_verified=true + is_banned=true mean?
}
// ✅ CORRECT: Only valid states are representable
enum UserStatus {
Unverified,
Verified,
Banned,
}
struct User {
status: UserStatus,
}// ❌ WRONG: Boolean flags with implicit dependencies
struct Order {
is_paid: bool,
is_shipped: bool,
is_delivered: bool,
// Can it be delivered but not shipped? Shipped but not paid?
}
// ✅ CORRECT: State machine with valid transitions only
enum OrderStatus {
Pending,
Paid,
Shipped,
Delivered,
Cancelled,
}
struct Order {
status: OrderStatus,
}// ❌ WRONG: Fields that only apply to certain states
struct Payment {
status: String,
error_message: Option<String>, // Only for "failed"
transaction_id: Option<String>, // Only for "completed"
retry_count: Option<u32>, // Only for "pending"
}
// ✅ CORRECT: Each variant carries only its relevant data
enum PaymentStatus {
Pending { retry_count: u32 },
Completed { transaction_id: String },
Failed { error_message: String },
}
struct Payment {
status: PaymentStatus,
}Apply this rule:
- Replace
Stringwith an enum when the field has a fixed set of valid values - Replace
boolwith an enum when the field represents more than true/false semantics - Replace multiple booleans with an enum when they have invalid combinations or dependencies
- Replace
Option<T>fields with enum variants carrying data when fields only apply to certain states
Benefits:
- Compiler rejects invalid states at compile time
- Pattern matching ensures all cases are handled
- Self-documenting: the type definition shows all possible states
- Refactoring is safe: adding a variant causes compile errors at all usage sites
When matching on enums you control, explicitly list all variants instead of using _ or .. catch-alls or if x == <enum-member>. This ensures the compiler alerts you when new variants are added.
// ❌ WRONG: Wildcard hides future variants
match role {
Admin => true,
_ => false,
}
// ❌ WRONG: If hides future variants
if role == Admin {
true
} else {
false
}
// ✅ CORRECT: Compiler will error when new variants are added
match role {
Admin => true,
Member => false,
}Why this matters:
- Adding
UserRole::Moderatorsilently falls intofalsewith the wildcard - Explicit matches force you to consider each case when the enum evolves
- The compiler becomes your safety net for refactoring
Use wildcards only when:
- Matching external crate enums marked
#[non_exhaustive] - Handling spec-driven enums with many variants (HTTP status codes, MIME types) where only a few need special handling
- The logic genuinely is "these specific cases vs. everything else" and won't change
Rule of thumb: Write out all match arms for enums you control. Use wildcards for 50-variant spec enums where you only care about 3 cases—but add a comment explaining the intent.
When a condition could indicate either "expected/acceptable" or "something is wrong," handle these cases explicitly and separately. Never combine them into a single fallback branch.
Principle: If a state would be a bug or misconfiguration in some contexts, make it loud. Silent fallbacks hide bugs and make debugging harder.
// ❌ WRONG: Silent fallback hides whether None is expected or a bug
fn get_user_display_name(user: &User) -> String {
match &user.display_name {
Some(name) => name.clone(),
None => "Anonymous".to_string(), // Is this intentional or hiding missing data?
}
}
// ✅ CORRECT: Separate functions with explicit intent
fn get_optional_display_name(user: &User) -> Option<&str> {
user.display_name.as_deref() // Caller decides how to handle None
}
fn get_required_display_name(user: &User) -> &str {
user.display_name.as_deref()
.expect("display_name is required for verified users")
}
// ✅ CORRECT: Different handling based on known context
match &config.database_url {
Some(url) => connect(url),
None => panic!("DATABASE_URL is required"), // Required config: fail loud
}
match &config.cache_url {
Some(url) => Some(connect_cache(url)),
None => None, // Optional config: absence is valid
}Check for silent fallbacks when handling:
- Missing environment variables or configuration — panic if required, skip if optional
- Failed parsing or validation — return error, don't substitute defaults
- Unexpected enum variants — panic with context, don't silently ignore
- Empty collections — fail if data is expected, proceed if emptiness is valid
- Network/IO operations — propagate errors, don't silently retry or ignore
When in doubt: Ask a user whether absence/failure is expected or a bug.