docs: API design patterns from rust-lang/rust

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.
This commit is contained in:
Rodin
2026-04-30 15:10:23 -07:00
parent 9cd0a33ff9
commit 7016c427e8
+596
View File
@@ -0,0 +1,596 @@
# Rust API Design Patterns
Patterns for designing public APIs in Rust, extracted from the
standard library source.
**Source:** [rust-lang/rust](https://github.com/rust-lang/rust) at commit
[`f53b654`](https://github.com/rust-lang/rust/tree/f53b654a8882fd5fc036c4ca7a4ff41ce32497a6)
**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/.
```rust
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, &self
- `as_` → Cheap conversion, returns a reference, &self
- `to_` → Potentially expensive, may allocate, &self
- `into_` → 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:**
```rust
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:**
```rust
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)
- `new` for constructors (not `create_` or `make_`)
- `get`/`set` for simple field access on non-collection types
---
## 2. #[non_exhaustive] for Future-Proof Enums
### Source:
[library/std/src/io/error.rs](https://github.com/rust-lang/rust/blob/f53b654a8882fd5fc036c4ca7a4ff41ce32497a6/library/std/src/io/error.rs) (ErrorKind)
53 `#[non_exhaustive]` annotations in the stdlib.
```rust
#[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:**
```rust
pub enum Color { Red, Green, Blue }
// Later, adding Yellow breaks ALL downstream match statements:
// error: non-exhaustive patterns: `Yellow` not covered
```
**Example — after:**
```rust
#[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.
```rust
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:**
```rust
// Private — can't use from sibling modules
fn helper() { ... }
// Public — now it's part of your API contract forever
pub fn helper() { ... }
```
**Example — after:**
```rust
// 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.
```rust
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 construction
- `with_capacity()` — pre-allocating variant
- `from_*()` — construction from specific inputs (not From trait)
- Builder pattern — many optional parameters
**Example — before:**
```rust
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:**
```rust
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
fallible `build()`)
- 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.
```rust
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:**
```rust
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:**
```rust
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:
```rust
// 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:**
```rust
// Only works with String — can't pass &str, PathBuf, etc.
fn log_to_file(path: String, message: String) { ... }
```
**Example — after:**
```rust
// 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 Trait` instead)
- 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.
```rust
// 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:
```rust
// 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:
```rust
// 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:**
```rust
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:**
```rust
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 (`.0` access, 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`.
```rust
// 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](traits.md) — Trait design patterns
- [error-handling.md](error-handling.md) — Error type design
- [documentation.md](documentation.md) — Documenting public APIs
<!-- PATTERN_COMPLETE -->