10 patterns, 596 lines. Full spec compliance. Patterns: naming conventions, #[non_exhaustive], pub(crate), constructors, method chaining, trait bounds, typestate, extension traits, newtype, per-module errors.
16 KiB
Rust API Design Patterns
Patterns for designing public APIs in Rust, extracted from the standard library source.
Source: rust-lang/rust at commit
f53b654
Stats: 18,430 pub fn, 919 pub(crate), 53 #[non_exhaustive], 889 is_/as_/to_/into_ methods, 162 new/build constructors, 2,676 impl blocks.
1. Naming Conventions (is_, as_, to_, into_)
Source:
889 methods following these naming conventions in library/.
impl Option<T> {
pub fn is_some(&self) -> bool { ... } // is_ → bool check
pub fn as_ref(&self) -> Option<&T> { ... } // as_ → cheap ref conversion
}
impl String {
pub fn as_str(&self) -> &str { ... } // as_ → borrow (no allocation)
pub fn into_bytes(self) -> Vec<u8> { ... } // into_ → consuming conversion
}
impl Path {
pub fn to_str(&self) -> Option<&str> { ... } // to_ → potentially expensive
pub fn to_string_lossy(&self) -> Cow<str> { ... } // to_ → may allocate
}
Why
The prefix tells you the cost and ownership semantics without reading the implementation:
is_→ Returns bool, no allocation, &selfas_→ Cheap conversion, returns a reference, &selfto_→ Potentially expensive, may allocate, &selfinto_→ Consumes self, zero-cost ownership transfer
When to Use
Triggers:
is_: Predicate (yes/no check on &self)as_: Cheap view (reinterpret bits, return &T)to_: Conversion that may allocate or compute (returns owned)into_: Consuming conversion (self → OtherType)
Example — before:
impl Email {
fn get_domain(&self) -> &str { ... } // "get" prefix — Java style
fn make_lowercase(self) -> Email { ... } // "make" — unclear cost
fn check_valid(&self) -> bool { ... } // "check" — inconsistent
}
Example — after:
impl Email {
fn as_domain(&self) -> &str { ... } // as_ → cheap, returns ref
fn into_lowercase(self) -> Email { ... } // into_ → consumes self
fn is_valid(&self) -> bool { ... } // is_ → predicate
fn to_ascii(&self) -> String { ... } // to_ → allocates new String
}
When NOT to Use
Don't use this when:
- The method doesn't fit any prefix (use a descriptive verb)
newfor constructors (notcreate_ormake_)get/setfor simple field access on non-collection types
2. #[non_exhaustive] for Future-Proof Enums
Source:
library/std/src/io/error.rs (ErrorKind)
53 #[non_exhaustive] annotations in the stdlib.
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
PermissionDenied,
ConnectionRefused,
// ... can add more variants without breaking downstream
}
Why
Without #[non_exhaustive], adding an enum variant is a breaking
change (downstream match statements become non-exhaustive).
With it, match statements require a wildcard arm _ =>, so you
can add variants freely in minor versions.
When to Use
Triggers:
- Public enums in libraries that may grow
- Error kind enums (new error types are common)
- Status/state enums that evolve over time
- Struct fields you might add to later
Example — before:
pub enum Color { Red, Green, Blue }
// Later, adding Yellow breaks ALL downstream match statements:
// error: non-exhaustive patterns: `Yellow` not covered
Example — after:
#[non_exhaustive]
pub enum Color { Red, Green, Blue }
// Downstream must have wildcard:
match color {
Color::Red => "red",
Color::Green => "green",
Color::Blue => "blue",
_ => "unknown", // required — handles future variants
}
// Adding Color::Yellow in next release is non-breaking!
When NOT to Use
Don't use this when:
- The enum is closed (boolean-like: On/Off)
- Internal use only (pub(crate))
- You WANT exhaustive matching for safety (Option, Result)
3. pub(crate) for Internal Visibility
Source:
919 pub(crate) usages in the stdlib.
pub(crate) fn internal_helper() { ... }
pub(crate) struct InternalState { ... }
Why
pub(crate) makes items visible within the crate but not to
external users. It's the middle ground between pub (everyone)
and private (only the module). Keeps internal architecture
flexible without exposing implementation details.
When to Use
Triggers:
- Helper functions used across modules but not part of the public API
- Internal types that multiple modules need but users shouldn't see
- Test utilities that need cross-module access
Example — before:
// Private — can't use from sibling modules
fn helper() { ... }
// Public — now it's part of your API contract forever
pub fn helper() { ... }
Example — after:
// Visible to the whole crate, invisible to users
pub(crate) fn helper() { ... }
When NOT to Use
Don't use this when:
- The item is only used in one module (just make it private)
- The item should be part of the public API (use
pub) - You're in a binary crate (there are no external users anyway)
4. Constructor Pattern (fn new)
Source:
162 new/build methods in the stdlib.
impl Vec<T> {
pub const fn new() -> Self {
Vec { ptr: NonNull::dangling(), len: 0, cap: 0 }
}
pub fn with_capacity(capacity: usize) -> Self { ... }
}
Why
new() is the standard constructor name in Rust (not create,
init, make). Parameterized constructors use with_ prefix or
a builder pattern. new() should be cheap and infallible when
possible.
When to Use
Triggers:
new()— default or minimal constructionwith_capacity()— pre-allocating variantfrom_*()— construction from specific inputs (not From trait)- Builder pattern — many optional parameters
Example — before:
impl Server {
pub fn create(host: &str, port: u16, threads: usize, tls: bool) -> Self { ... }
// 4 positional parameters — easy to mix up port and threads
}
Example — after:
impl Server {
pub fn new(addr: SocketAddr) -> Self { ... } // minimal required args
pub fn builder() -> ServerBuilder { ... } // for complex configuration
}
impl ServerBuilder {
pub fn threads(mut self, n: usize) -> Self { self.threads = n; self }
pub fn tls(mut self, enabled: bool) -> Self { self.tls = enabled; self }
pub fn build(self) -> Result<Server, ConfigError> { ... }
}
When NOT to Use
Don't use this when:
- Construction can fail (use
try_new()→ Result, or builder with falliblebuild()) - Multiple constructors exist (use descriptive names:
from_path,from_reader)
5. Method Chains (Consuming Self → Self)
Source:
Iterator adapters are the definitive example of method chaining.
let result: Vec<i32> = vec![1, 2, 3, 4, 5]
.into_iter()
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect();
Why
Method chaining creates fluent, readable APIs. Each method consumes self and returns a new (or modified) Self. The ownership model makes this natural — no mutation through shared references.
When to Use
Triggers:
- Builder patterns (each step returns modified builder)
- Iterator adapters (each step wraps the previous)
- Configuration objects
Example — before:
let mut query = Query::new("users");
query.set_filter("age > 18");
query.set_limit(10);
query.set_order("name");
let results = query.execute()?;
Example — after:
let results = Query::new("users")
.filter("age > 18")
.limit(10)
.order_by("name")
.execute()?;
When NOT to Use
Don't use this when:
- Operations can fail mid-chain (errors get awkward)
- The object needs to be reused after each call
- It makes the code LESS readable (very long chains)
6. Generic Parameters with Trait Bounds
Source:
// Accept anything that can be referenced as a Path:
pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> { ... }
// Accept anything comparable:
pub fn max<T: Ord>(a: T, b: T) -> T { ... }
// Multiple bounds:
pub fn sort_and_print<T: Ord + Display>(items: &mut [T]) { ... }
Why
Generics with trait bounds give you maximum flexibility while maintaining type safety. The function works with ANY type that satisfies the bounds — but the compiler still checks everything at compile time.
When to Use
Triggers:
- Function should work with multiple concrete types
- You want to accept user-defined types that implement your trait
- Performance (monomorphization — no virtual dispatch)
Example — before:
// Only works with String — can't pass &str, PathBuf, etc.
fn log_to_file(path: String, message: String) { ... }
Example — after:
// Works with any type that can cheaply become &Path and &str
fn log_to_file(path: impl AsRef<Path>, message: impl AsRef<str>) {
let path = path.as_ref();
let message = message.as_ref();
// ...
}
// Now accepts: "file.txt", String::from("file.txt"), PathBuf, etc.
When NOT to Use
Don't use this when:
- Only one type will ever be used (concrete type is simpler)
- You need dynamic dispatch (use
dyn Traitinstead) - The generic makes the signature unreadable (too many bounds)
7. Typestate Pattern (Compile-Time State Machines)
Source:
The stdlib uses this in File (open modes) and throughout.
The pattern encodes state in the type system.
// Different types for different states — impossible to misuse
struct Connection<State> { inner: TcpStream, _state: PhantomData<State> }
struct Connecting;
struct Connected;
struct Authenticated;
impl Connection<Connecting> {
fn connect(addr: &str) -> Result<Connection<Connected>, Error> { ... }
}
impl Connection<Connected> {
fn authenticate(self, creds: &Creds) -> Result<Connection<Authenticated>, Error> { ... }
}
impl Connection<Authenticated> {
fn query(&self, sql: &str) -> Result<Rows, Error> { ... }
}
// Can't call query() without authenticating first — compile error!
Why
The type system prevents invalid state transitions. You literally cannot call methods in the wrong order because they don't exist on that type state. Bugs become compile errors.
When to Use
Triggers:
- State machine with ordered transitions
- Protocol sequences (connect → auth → use)
- Builder with required steps
When NOT to Use
Don't use this when:
- States are dynamic (determined at runtime)
- There are too many states (exponential type explosion)
- The API becomes confusing (type parameters everywhere)
8. Extension Traits for Adding Methods
Source:
// std adds methods to Iterator via extension traits:
pub trait IteratorExt: Iterator + Sized {
fn take_while_ref<P>(&mut self, pred: P) -> TakeWhileRef<Self, P> { ... }
}
impl<I: Iterator> IteratorExt for I {}
Why
Extension traits add methods to types you don't own (from another crate) without modifying the original. Users must import the extension trait to see the methods.
When to Use
Triggers:
- Adding methods to external types (from other crates)
- Optional functionality (only available when you import the trait)
- Platform-specific extensions (e.g.,
std::os::unix::fs::PermissionsExt)
When NOT to Use
Don't use this when:
- You own the type (just add methods directly)
- A free function would be clearer
- The extension is confusing (users won't find it via autocomplete)
9. Newtype Pattern for Type Safety
Source:
// Newtype: single-field tuple struct
pub struct Instant(time::Instant);
pub struct Duration { secs: u64, nanos: Nanoseconds }
// Newtypes prevent mixing up semantically different values:
struct Meters(f64);
struct Seconds(f64);
// Can't accidentally pass Meters where Seconds is expected
Why
Newtypes create distinct types with zero runtime cost (same representation). They prevent confusing values that have the same underlying type but different semantics.
When to Use
Triggers:
- Multiple parameters of the same primitive type (easy to swap)
- Domain concepts that should be distinct (UserID vs PostID)
- Adding methods or trait impls to foreign types
Example — before:
fn schedule_meeting(room_id: u64, user_id: u64, duration_mins: u64) { ... }
// Easy to accidentally swap room_id and user_id — both are u64!
schedule_meeting(user_id, room_id, 60); // compiles fine — but wrong
Example — after:
struct RoomId(u64);
struct UserId(u64);
struct Minutes(u64);
fn schedule_meeting(room: RoomId, user: UserId, duration: Minutes) { ... }
// schedule_meeting(user_id, room_id, Minutes(60)); // COMPILE ERROR
When NOT to Use
Don't use this when:
- Only one parameter of that type exists (no confusion possible)
- The wrapper adds no safety (just wrapping for the sake of it)
- The ergonomic cost (
.0access, impl forwarding) outweighs benefit
10. Error Type Design (Per-Module Errors)
Source:
The stdlib provides one error type per module: io::Error,
fmt::Error, num::ParseIntError, str::Utf8Error.
// Each module has its own error type:
pub mod io {
pub struct Error { repr: Repr }
pub type Result<T> = std::result::Result<T, Error>;
}
pub mod num {
pub struct ParseIntError { kind: IntErrorKind }
}
Why
Per-module errors keep error types focused. io::Error handles
all I/O errors with a kind enum + optional payload. This is more
maintainable than one giant application error enum.
When to Use
Triggers:
- Your module is self-contained with its own failure modes
- Different modules have different error semantics
- You want to provide
type Result<T>convenience alias
When NOT to Use
Don't use this when:
- The module is tiny (just use the parent's error type)
- Errors need to compose across modules (consider
thiserror/anyhow)
Summary: API Design Decision Tree
Naming a method?
├── Returns bool? → is_*
├── Returns &T (cheap)? → as_*
├── Returns T (may allocate)? → to_*
├── Consumes self → T? → into_*
├── Constructor? → new / with_* / from_*
└── Other? → descriptive_verb
Visibility?
├── External API → pub
├── Cross-module internal → pub(crate)
└── Module-only → private (default)
Enum might grow?
└── YES → #[non_exhaustive]
Many parameters?
└── YES → Builder pattern (fn builder() → Builder)
Accept multiple types?
├── Cheap reference → impl AsRef<T>
├── Ownership transfer → impl Into<T>
└── Multiple bounds → T: Bound1 + Bound2
Prevent misuse?
├── Wrong argument order → Newtype pattern
├── Wrong method order → Typestate pattern
└── Future compatibility → #[non_exhaustive]
| Pattern | Use when |
|---|---|
is_/as_/to_/into_ |
Standard naming convention |
#[non_exhaustive] |
Enum/struct may gain variants/fields |
pub(crate) |
Internal cross-module access |
fn new / builder |
Construction |
| Method chaining | Fluent APIs (config, queries, iterators) |
| Trait bounds | Generic functions accepting multiple types |
| Typestate | Compile-time state machine enforcement |
| Extension traits | Adding methods to foreign types |
| Newtype | Type-safe wrappers around primitives |
| Per-module errors | Self-contained error types per module |
See also:
- traits.md — Trait design patterns
- error-handling.md — Error type design
- documentation.md — Documenting public APIs