Files
Rodin 7016c427e8 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.
2026-04-30 15:10:23 -07:00

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, &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:

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)
  • 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 (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 construction
  • with_capacity() — pre-allocating variant
  • from_*() — 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 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.

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 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.

// 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 (.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.

// 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: