Designing Expressive Types
Designing expressive types means shaping structs, enums, and trait bounds so that every representable value is a valid domain state and every invalid domain state is unrepresentable. When invariants are encoded directly into the type structure, functions do not need defensive runtime assertions or unreachable error branches.
Core Principles of Expressive Type Design
1. Push Option Inside into a Sum Type (enum) Instead of Parallel Option Fields
A struct with multiple correlated Option fields allows contradictory states (such as a network response having both body: Some(...) and error_message: Some(...), or neither):
Weak Representation (`2 * 2 = 4` states, 2 of which are invalid!):
struct Outcome {
value: Option<String>,
error: Option<ErrorCode>,
}
Expressive Representation (Only the valid states can be constructed!):
enum Outcome {
Success(String),
Failure(ErrorCode),
}
2. Replace Boolean Parameter Lists with Self-Documenting Enums
A function call like connect("db.internal", true, false) forces readers to look up the function definition to know which boolean is use_tls and which is read_only, and does not prevent passing them in the wrong order. Replacing raw bool arguments with two-variant enums makes every call site self-documenting and type-checked:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlsMode {
Required,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessMode {
ReadOnly,
ReadWrite,
}
pub fn open_database(host: &str, tls: TlsMode, access: AccessMode) {
println!("connecting to {host:?} (tls = {tls:?}, access = {access:?})");
}
fn main() {
// Impossible to swap `TlsMode` and `AccessMode` at call sites!
open_database("db.internal", TlsMode::Required, AccessMode::ReadOnly);
}
connecting to "db.internal" (tls = Required, access = ReadOnly)
3. Encode Non-Empty Guarantees at the Structural Level (NonEmpty<T>)
When an algorithm (such as selecting a primary leader replica or computing a minimum/maximum) requires at least one element, storing (head: T, tail: Vec<T>) inside a NonEmpty<T> struct makes an empty collection structurally impossible—allowing .first() and .reduce() to return &T and T directly instead of Option<&T>:
#[derive(Debug, Clone)]
pub struct NonEmpty<T> {
head: T,
tail: Vec<T>,
}
impl<T> NonEmpty<T> {
pub const fn singleton(head: T) -> Self {
Self {
head,
tail: Vec::new(),
}
}
pub fn from_vec(mut vec: Vec<T>) -> Option<Self> {
if vec.is_empty() {
None
} else {
let head = vec.remove(0);
Some(Self { head, tail: vec })
}
}
// Returns `&T` directly (never `Option<&T>`!) because `head` always exists:
pub const fn first(&self) -> &T {
&self.head
}
pub fn push(&mut self, item: T) {
self.tail.push(item);
}
pub fn len(&self) -> usize {
1 + self.tail.len()
}
}
impl<T: Ord> NonEmpty<T> {
// Guaranteed to return `&T` without `Option` or `unwrap()`!
pub fn max(&self) -> &T {
self.tail
.iter()
.fold(&self.head, |acc, item| if item > acc { item } else { acc })
}
}
fn main() {
let mut replicas = NonEmpty::singleton(120_u32);
replicas.push(450);
replicas.push(310);
println!(
"primary = {}, max = {}, len = {}",
replicas.first(),
replicas.max(),
replicas.len()
);
}
primary = 120, max = 450, len = 3
4. Mark Must-Inspect Types with #[must_use]
Applying #[must_use = "reason..."] to custom guard types, builder structs, validation tokens, and lazy combinators warns callers at compile time if they accidentally ignore a returned value without binding or consuming it.
| Weak / Stringly-Typed Pattern | Expressive Type Replacement |
|---|---|
timeout_ms: u64 (is 0 disabled or immediate timeout?) |
Option<NonZeroU64> or Option<Duration> (niche-optimized!) |
status: &str ("pending", "active", "closed") |
enum Status { Pending, Active, Closed } |
items: Vec<T> with assert!(!items.is_empty()) |
NonEmpty<T> { head: T, tail: Vec<T> } |
fn deploy(prod: bool, dry_run: bool) |
fn deploy(env: Environment, mode: ExecutionMode) |