Rust API Design (Rust API Guidelines)
Authority: Rust API Guidelines — the de-facto standard checklist of ~100 C-* rules used by std, tokio, serde, bevy. See the full checklist for canonical wording of every rule.
This skill owns the shape of a public Rust API: types, traits, naming, conversions, and the boundaries that keep the API usable, ergonomic, and resistant to breakage. It does not own Cargo manifest (rust-cargo-build), workspace topology (rust-workspace), in-crate src/ layout (rust-module-layout), or semver/publish (rust-semver).
Capability Boundaries
✅ Strengths
- Translating API Guidelines' ~100 C-* rules into concrete design decisions for a public crate
- Naming types, functions, methods, and features to match std and ecosystem conventions
- Choosing between generics, trait objects, concrete types, and the newtype pattern
- Designing
From/Into/TryFrom/AsRef/Borrowconversions (rejectingDerefpolymorphism) - Picking which auto traits (
Debug,Clone,Eq,Hash,Send,Sync) to derive - Sealing traits, marking
#[non_exhaustive], hiding struct fields — to reserve room to evolve - Replacing bool parameters with enums, raw integers with newtypes,
Stringwith&str - Designing iterators,
Extend,Default,Display/FromStrcorrectly - Avoiding
panic!/unwrap/expectin public APIs in favor ofResult
⚠️ Prerequisites
- Rust ownership, traits, lifetimes — see
rust-stable - Module visibility and re-exports — see
rust-module-layout - Cargo manifest for
[features],optional = true— seerust-cargo-build
❌ Out of Scope
- Semver rules and publish workflow →
rust-semver - Clippy lint configuration →
rust-style-clippy - Doctests and rustdoc comments →
rust-documentation - Workspace topology →
rust-workspace
Data Privacy
This skill does not collect, store, or transmit user data.
The Eight Chapters (One Section Per API Guidelines Chapter)
The Guidelines are organized into 11 chapters. This skill owns 8 — the design chapters. Documentation, Macros, and Necessities live in rust-documentation, rust-macros, and rust-cargo-build respectively.
1. Naming (C-CASE, C-CONV, C-GETTER, C-NAMING)
C-CASE — casing conventions
| Item | Case | Example |
|---|---|---|
| Types (struct/enum/trait), modules, crates | UpperCamelCase |
HttpClient, tokio |
| Functions, methods, locals, fields | snake_case |
send_request |
| Constants, statics | SCREAMING_SNAKE_CASE |
MAX_RETRIES |
| Generic types | single UpperCamelCase letter or short word |
T, K, V, Req |
| Lifetimes | short 'a/'b or descriptive 'src |
'src |
| Features | kebab-case (Cargo enforces) |
serde-json |
Avoid ad-hoc abbreviations: BufferedReader is std-compliant; BufRdr is not.
C-CONV — conversion method naming
| Conversion | Prefix | Borrows? | Example |
|---|---|---|---|
| Cheap, borrowed | as_ |
Yes (&self → &T) |
as_slice, as_bytes |
| Cheap, owned | to_ (no alloc) / into_ (consumes self) |
Varies | to_vec, into_bytes |
| Expensive, returns new | to_ |
No (&self → T) |
to_lowercase |
| Consuming | into_ |
No (self → T) |
into_string, into_iter |
| Fallible | TryFrom/TryInto |
— | u32::try_from(byte) |
Requirements: (1) A method named as_X that returns an owned X is wrong — rename to to_X. (2) The matched inverse for mutable borrows is as_X_mut / X_mut.
C-GETTER — accessor naming
// ✅ C-GETTER compliant
pub struct Buffer { data: Vec<u8> }
impl Buffer {
pub fn data(&self) -> &[u8] { &self.data } // no get_ prefix
pub fn data_mut(&mut self) -> &mut [u8] { &mut self.data }
pub fn len(&self) -> usize { self.data.len() } // not get_len
pub fn is_empty(&self) -> bool { self.data.is_empty() }
}
Exceptions where get_ is allowed: Cell::get, Map::get (genuine lookup semantics).
2. Interoperability (C-COMMON-TRAITS, C-CONVERT, C-ITER, C-SERDE)
C-COMMON-TRAITS — derive the obvious traits
For every public type, ask: should this derive Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default?
| Trait | Default | Exception |
|---|---|---|
Debug |
Yes | Secrets → manual Debug that redacts |
Clone |
Yes if cheap | Expensive clone → omit + document |
Copy |
Only ≤16 bytes + lossless | — |
PartialEq/Eq |
Yes if total order | f64 cannot be Eq |
Hash |
Yes if Eq |
Must agree with Eq |
Default |
Yes if natural empty/zero | — |
C-CONVERT — From/Into/AsRef/Borrow; reject Deref polymorphism
// ✅ Impl From<T> for U; Into comes for free
impl From<ErrorKind> for Error { fn from(k: ErrorKind) -> Self { Error::Kind(k) } }
// ✅ AsRef for borrowed views
impl AsRef<str> for Name { fn as_ref(&self) -> &str { &self.0 } }
// ✅ Borrow<str> if Eq/Hash should agree with str
impl Borrow<str> for Name { fn borrow(&self) -> &str { &self.0 } }
Reject Deref as polymorphism: Deref is for smart pointers (Box, Rc, Arc, String — std precedent). Using Deref to make MyClient transparent to HttpClient gives hidden method injection, breaks &self resolution, and is the deref polymorphism anti-pattern. Use AsRef, an explicit method, or composition.
C-ITER — iterator design
// ✅ Provide IntoIterator for &T, &mut T, T when sensible
impl<'a> IntoIterator for &'a Grid {
type Item = &'a Cell;
type IntoIter = std::slice::Iter<'a, Cell>;
fn into_iter(self) -> Self::IntoIter { self.cells.iter() }
}
impl Grid {
pub fn iter(&self) -> impl Iterator<Item = &Cell> { /* */ }
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Cell> { /* */ }
}
impl Extend<Cell> for Grid { /* */ }
Rules: iter() borrows, iter_mut() mutably borrows, into_iter() consumes. Never return Vec from iteration methods — return impl Iterator.
C-SERDE — serialization interop
- Use
#[serde(rename_all = "kebab-case")]consistently within a type #[non_exhaustive]enums need#[serde(other)]for forward compat with unknown variants- Re-export serde under a feature (
serde = ["dep:serde"]) so downstream can opt out
3. Predictability (C-INTUITIVE, C-CONST, C-COMMON)
C-INTUITIVE — naming reflects semantics
If the caller had to read the source to know what read does, the API is wrong. Don't surprise: a read method that panics on EOF is a defect.
C-CONST — const fn where possible
pub const fn new(value: u32) -> Self { Self(value) }
const fn is_power_of_two(n: u32) -> bool { n != 0 && n & (n - 1) == 0 }
Every std lib API that can be const fn is a candidate. Enables const MAX: UserId = UserId::new(1000); in callers.
C-COMMON — sensible defaults via Default and builder
impl Default for Config {
fn default() -> Self {
Config { retry_count: 3, timeout: Duration::from_secs(30) }
}
}
// Builder for complex construction
let client = Client::builder().with_retry(3).with_timeout(Duration::from_secs(10)).build()?;
4. Flexibility (C-OVERLOAD, C-GENERIC, C-NEWTYPE, C-EXT)
C-GENERIC — generics on input, concrete on output
// ✅ Generic over AsRef<str> — caller passes &str, String, Cow
pub fn parse(input: impl AsRef<str>) -> Result<Foo> { /* */ }
// ❌ Concrete &str — forces caller to borrow
pub fn parse(input: &str) -> Result<Foo> { /* */ }
Trade-off: more generics → longer compile, harder diagnostics. Generic on input types (AsRef<str>, IntoIterator); concrete on output.
C-NEWTYPE — wrap primitives to prevent misuse
// ✅ Newtypes around raw primitives
pub struct UserId(pub u64);
pub struct Email(String); // private inner — can't be constructed unsafely
fn delete_user(id: UserId) { /* */ } // can't accidentally pass a PostId
// ❌ Plain primitives — confusion and argument-order bugs
fn delete_user(id: u64) { /* */ }
Zero-cost (compile to the underlying type), prevent argument-order bugs, and allow attaching methods (UserId::is_anonymous()).
C-EXT — extension traits via Ext suffix
pub trait StringExt { fn slugify(&self) -> String; }
impl StringExt for str { fn slugify(&self) -> String { /* */ } }
// Caller opts in:
use my_crate::StringExt;
"Hello World".slugify();
Don't put methods directly on String/Vec/HttpRequest from other crates — use an Ext trait.
5. Type Safety (C-BOOL, C-NONZERO, C-STR, C-SIGNED, C-BITFLAG, C-WRAPPER, C-INTERVAL)
Authority: API Guidelines — Type Safety. See
references/api-guidelines-checklist.mdfor canonical wording.
C-BOOL — replace bool parameters with enums
// ✅ Enum — caller intent is explicit at call site
pub enum Trim { Whitespace, None }
pub fn parse(input: &str, trim: Trim) -> Result<Foo> { /* */ }
parse(" x ", Trim::Whitespace);
// ❌ Bool — caller must remember what true means
pub fn parse(input: &str, trim: bool) -> Result<Foo> { /* */ }
parse(" x ", true); // true = ??
Two bool params compound: f(true, false, true) is incomprehensible. Two-arg enums are the floor. Set clippy.toml max-fn-params-bools = 1 and max-struct-bools = 1 to enforce mechanically.
C-NONZERO — NonZeroUsize when zero is invalid
use std::num::NonZeroUsize;
// ✅ NonZeroUsize encodes "≥ 1" in the type
pub fn chunk_size(&self) -> NonZeroUsize { /* */ }
Enables niche optimization: Option<NonZeroU32> is the same size as u32. Use NonZeroU8/NonZeroU16/NonZeroU32/NonZeroU64/NonZeroUsize and the NonZeroI* variants.
C-STR — &str not &String; &[T] not &Vec<T>
// ✅ Borrow slices for inputs
pub fn process(data: &[u8], name: &str) { /* */ }
// ❌ Forces caller to have owned collections
pub fn process(data: &Vec<u8>, name: &String) { /* */ }
C-SIGNED — prefer unsigned types when values can't be negative
// ✅ u64 — semantically "count" can't be negative
pub struct Counter { count: u64 }
// ❌ i64 — implies negative values are valid (they aren't)
pub struct Counter { count: i64 }
For special ranges (e.g., Age 0..=150), use a newtype with validating constructor — let the type system prevent invalid values.
C-BITFLAG — use the bitflags! macro for flag sets
use bitflags::bitflags;
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Permissions: u32 {
const READ = 0b001;
const WRITE = 0b010;
const EXECUTE = 0b100;
}
}
let p = Permissions::READ | Permissions::WRITE; // type-checked composition
assert!(p.contains(Permissions::READ)); // built-in methods
Avoid raw u32 for flag sets — lose type safety, lose contains/insert/remove/intersects.
C-WRAPPER — newtype to give primitive types meaningful semantics
pub struct UserId(pub u64);
pub struct AccountId(pub u64);
pub struct OrderId(pub u64);
// Compiler rejects wrong-id bugs:
fn transfer(from: AccountId, to: AccountId, amount: Cents) { /* */ }
// transfer(UserId(1), UserId(2), Cents(100)) ← compile error
Zero-cost at runtime (compile to underlying type). Use #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] by default.
C-INTERVAL — encode ranges as types, not loose pairs
// ✅ Dedicated range type with validating constructor
pub struct ChunkRange { start: u32, end: u32 } // invariant: end >= start
impl ChunkRange {
pub fn new(start: u32, end: u32) -> Result<Self, RangeError> {
if end < start { return Err(RangeError::Inverted); }
Ok(Self { start, end })
}
pub fn contains(&self, x: u32) -> bool { self.start <= x && x <= self.end }
}
// ❌ Loose pair — caller might pass end < start
pub fn process_chunk(start: u32, end: u32) { /* */ }
For std ranges, use RangeInclusive/Range/RangeTo. For domain ranges (pagination, time windows), wrap in a newtype.
C-COMMENT-HIDDEN — #[doc(hidden)] does NOT exclude from public API
// ❌ Hides from rustdoc but is still semver-relevant
#[doc(hidden)]
pub mod unstable { /* */ } // downstream can still `use crate::unstable::Foo;`
// ✅ For actually-unstable items, gate behind a feature
#[cfg(feature = "unstable")]
pub mod unstable { /* */ }
#[doc(hidden)] only hides from cargo doc. For semver/stability, use feature flags or module privacy.
For owned inputs, accept String/Vec or impl Into<String>/impl IntoIterator.
6. Dependability (C-PANIC, C-UNWRAP, C-TRANSMUTE)
C-PANIC — panic only for unreachable invariants
// ✅ Documented, unreachable from public API
pub fn lookup(&self, id: UserId) -> &User {
self.table.get(&id).expect("internal: index invariant broken")
}
// ❌ Panics on user input
pub fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 { panic!("division by zero") }
a / b
}
// ✅ Return Result for fallible operations
pub fn divide(a: f64, b: f64) -> Result<f64, DivError> { /* */ }
Acceptable: programmer violated an invariant, or function documented infallible. Unacceptable: input-driven failure.
C-UNWRAP — unwrap/expect forbidden in public paths
// ❌ Library code
pub fn parse(input: &str) -> Config {
serde_json::from_str(input).unwrap() // panics on bad input
}
// ✅ Propagate
pub fn parse(input: &str) -> Result<Config, ParseError> {
Ok(serde_json::from_str(input)?)
}
unwrap is OK in tests, const contexts (no ?), and proven-unreachable code. Use expect("reason") over bare unwrap() for diagnostics.
C-TRANSMUTE — never std::mem::transmute for type punning
transmute reinterprets bits, bypassing the type system. Use From/Into/TryFrom, bytemuck::cast/zerocopy (verified byte-cast), u64::from_ne_bytes/to_ne_bytes, or as for widening. If you reach for transmute, route to rust-unsafe-ffi.
7. Debuggability (C-DEBUG)
Every public type implements Debug. Missing Debug blocks debugging; over-sharing in Debug leaks secrets.
#[derive(Debug)]
pub struct Client { /* */ }
// ✅ Manual Debug for secrets
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Password(***)")
}
}
8. Future-Proofing (C-SEALED, C-STRUCT-FIELD, C-NON-EXHAUSTIVE)
C-SEALED — seal traits to prevent external impls
mod private { pub trait Sealed {} }
pub trait Serializer: private::Sealed {
fn serialize(&self, v: &impl Serialize);
}
pub struct JsonSerializer;
impl private::Sealed for JsonSerializer {}
impl Serializer for JsonSerializer { /* */ }
Without sealing, anyone can impl MyTrait for TheirType, locking you out of adding methods (would be a breaking change). See references/future-proofing.md.
C-STRUCT-FIELD — public struct fields are stable forever
// ✅ Private fields, public constructor — adding fields is non-breaking
pub struct Config { retry_count: u32, timeout: Duration }
impl Config {
pub fn new(retry: u32, timeout: Duration) -> Self { Self { retry_count: retry, timeout } }
pub fn builder() -> ConfigBuilder { /* */ }
}
// ❌ Pub fields + struct literal — adding a field breaks callers
let c = Config { retry_count: 3, timeout: Duration::from_secs(30) };
Rule: pub field = stable forever. Private field = evolvable.
C-NON-EXHAUSTIVE — mark enums that will grow
#[non_exhaustive]
pub enum Error { Io(io::Error), Parse(ParseError) }
// Downstream must use a wildcard arm:
match err {
Error::Io(e) => /* */,
Error::Parse(e) => /* */,
_ => /* unknown */,
}
Use on error/event/status enums you intend to extend, and on structs with public fields you may add to.
Workflow
- Inventory the public surface —
cargo doc --openand list everypubitem. That's the contract. - Apply naming rules (Section 1) — fix case, conversion prefixes (
as_/to_/into_), dropget_. - Apply interop rules (Section 2) — derive
Debug/Clone/etc., addFrom/AsRef, design iterators. - Apply predictability + flexibility (Sections 3-4) —
Default,const fn, newtypes for primitives,Exttraits. - Apply type safety (Section 5) — bool→enum,
NonZero*,&strover&String. - Apply dependability (Section 6) — remove
unwrap/panic!from public paths; returnResult. - Apply future-proofing (Section 8) — seal extensible traits,
#[non_exhaustive]on growing enums, private struct fields. - Hand off — semver check →
rust-semver; doctests →rust-documentation; lint config →rust-style-clippy.
Decision Shortcuts
| Question | Answer |
|---|---|
Option or Result? |
Option for "may not exist"; Result for "may fail" |
| Trait or enum? | Trait if open (others add impls); enum if closed (you own variants) |
| Generic or concrete? | Generic on input, concrete on output |
Field pub? |
Default no. Only if struct-literal construction is intended |
&str or impl AsRef<str>? |
AsRef<str> for flexibility; &str for simplicity |
Derive Copy? |
Only if small (≤16 bytes) and bitwise copy is correct |
| Seal this trait? | Yes, unless third parties must add impls (rare) |
#[non_exhaustive]? |
Yes for error/event enums; yes for structs with pub fields that may grow |
Anti-Pattern Catalog
Derefpolymorphism —Derefto "inherit" methods. Use composition.get_Xaccessors — dropget_.fn data(&self) -> &[u8].- Bool params —
fn f(x: bool, y: bool). Use two enums. unwrapin public API — propagates panics. UseResult.String/Vecin inputs — forces ownership. Use&str/&[T].- Unsealed extension traits — locks you out of future methods. Seal.
#[non_exhaustive]missing on errors — adding a variant breaks downstream.- Newtype missing —
fn transfer(amount: u64, from: u64, to: u64)— argument order is a footgun. - Generated
Debugleaking secrets —#[derive(Debug)] struct ApiKey(String). Custom redact. transmutefor casts — useFrom/as/from_ne_bytes.
See examples/anti-patterns.md for before/after refactors of each.
Resources
- API Guidelines Checklist — the full ~100 C-* rules with examples
- Naming and Conversions Deep Dive
- Future-Proofing Patterns — sealed traits, non_exhaustive, builder patterns
- Anti-Pattern Catalog — before/after refactors
examples/golden-api/— a small crate that follows every rule,cargo doc+ clippy passing