Advanced Rust Design Patterns

Designing Ergonomic Rust Libraries

Designing ergonomic Rust libraries applies the official Rust API Guidelines across naming conventions, generic conversion traits (AsRef, Into), standard trait derives, and predictable borrowing signatures so callers can use your crate naturally without fighting unnecessary type conversions.

Pillars of an Ergonomic Rust Library API:
  1. Naming Conventions (`as_*` free, `to_*` copies, `into_*` consumes)
  2. Flexible Inputs (`impl AsRef<Path>`, `impl Into<String>`, `impl IntoIterator`)
  3. Concrete, Informative Outputs (Return concrete structs/enums, not overly generic types)
  4. Eager Standard Trait Derives (`Debug`, `Clone`, `PartialEq`, `Eq`, `Hash`, `Default`)
  5. Thread-Safety by Default (`Send + Sync` verified by compile-time test assertions)

1. Conversion Method Naming Conventions (as_, to_, into_)

Rust developers rely on three method prefixes to know the ownership and allocation cost of a conversion at a glance:

Prefix Receiver Cost Standard Library Examples
as_* &self (or Copy self) Free ($O(1)$ borrowed view) String::as_str(&self) -> &str, Path::as_os_str(&self), Vec::as_slice(&self)
to_* &self Expensive (allocates or converts bytes) str::to_lowercase(&self) -> String, CStr::to_string_lossy(&self), u32::to_be_bytes(self)
into_* self (consumes owner) Variable (often $O(1)$ ownership unwrap!) String::into_bytes(self) -> Vec<u8>, BufWriter::into_inner(self), Vec::into_boxed_slice(self)

Similarly, for collection and wrapper accessors:

  • Omit the get_ prefix on simple field accessors: name the method fn len(&self) or fn host(&self) -> &str (not get_len() or get_host()). Reserve get for indexed/keyed lookups that return Option<&T> (slice.get(i), map.get(&k)).
  • Pair every .iter(&self) method with .iter_mut(&mut self) and .into_iter(self) (plus the three IntoIterator implementations).

2. Accepting Generic Conversions While Avoiding Monomorphization Bloat

When Your Function Needs... Accept Parameter Type Example Call Sites Enabled
To read a string slice without owning it &str (simple, zero monomorphization!) "literal", &my_string, &cow_str (via Deref coercion!)
To take ownership of a String (storing it in a struct) impl Into<String> "literal" (allocates inside), my_owned_string (moves with 0 extra clones!)
To read a file system path impl AsRef<Path> "config.toml", Path::new("..."), PathBuf, OsString
To read an arbitrary byte buffer &[u8] or impl AsRef<[u8]> b"bytes", &vec_u8, "utf8_str" (since str: AsRef<[u8]>)
use std::path::{Path, PathBuf};

/// Eagerly derives standard traits so callers can log (`Debug`), compare (`PartialEq`, `Eq`),
/// clone (`Clone`), hash (`Hash`), and construct defaults (`Default`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ArchiveEntry {
    path: PathBuf,
    label: String,
    compressed_size: u64,
}

impl ArchiveEntry {
    /// Accepts `impl Into<PathBuf>` and `impl Into<String>`:
    /// - If caller passes an owned `PathBuf` or `String`, it moves in O(1) with 0 clones!
    /// - If caller passes `&str`, it converts ergonomically at the boundary.
    pub fn new(path: impl Into<PathBuf>, label: impl Into<String>, compressed_size: u64) -> Self {
        Self {
            path: path.into(),
            label: label.into(),
            compressed_size,
        }
    }

    /// Follows Rust `as_*` / prefix-free getter conventions:
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn label(&self) -> &str {
        &self.label
    }

    pub fn compressed_size(&self) -> u64 {
        self.compressed_size
    }

    /// Follows Rust `into_*` consuming convention to unwrap the owned `(PathBuf, String)` in O(1)!
    pub fn into_parts(self) -> (PathBuf, String) {
        (self.path, self.label)
    }
}

fn main() {
    // Caller can mix `&str` literals and owned `PathBuf`/`String` values effortlessly:
    let owned_label = String::from("manifest");
    let entry = ArchiveEntry::new("meta/Cargo.toml", owned_label, 1420);

    assert_eq!(entry.path(), Path::new("meta/Cargo.toml"));
    assert_eq!(entry.label(), "manifest");
    assert_eq!(entry.compressed_size(), 1420);

    let (path_buf, label_str) = entry.into_parts();
    println!("Extracted owned parts in O(1): path={}, label={label_str}", path_buf.display());
}
Extracted owned parts in O(1): path=meta/Cargo.toml, label=manifest