Guide contents

Lesson 84 of 94

API Design Idioms

A good API makes the right thing easy and the wrong thing impossible, or at least a compile error. Rust gives you many tools for that: ownership in signatures, enums, traits you can implement for your own types, and conversions that plug into the standard library. This lesson collects the idioms that experienced Rust developers use, many of them from the official Rust API Guidelines.

The recurring theme: encode rules in types so the compiler checks them, instead of in doc comments that callers may not read.

Part 1: Conventions

1. Naming conventions

Rust naming follows predictable patterns that tell callers about cost and ownership before they read the docs.

Pattern Meaning Examples
as_x() cheap view, borrowed to borrowed as_str(), as_bytes(), as_ref()
to_x() expensive conversion, usually allocates to_string(), to_vec(), to_lowercase()
into_x() consumes self, reuses its storage into_bytes(), into_inner(), into_iter()
x() (no get_) getter len(), name(), capacity()
x_mut() mutable getter get_mut(), last_mut(), name_mut()
is_x(), has_x() boolean query is_empty(), is_some(), has_permission()
new, with_x, from_x constructors Vec::new(), Vec::with_capacity(n), String::from_utf8(v)
try_x() fallible version returning Result/Option try_from(), try_lock()
iter(), iter_mut(), into_iter() iterate by &, &mut, value on every collection
Rust
pub struct Username(String);

impl Username {
    pub fn new(raw: &str) -> Username {
        Username(raw.trim().to_lowercase())
    }
    pub fn as_str(&self) -> &str {
        &self.0 // cheap: just a view
    }
    pub fn len(&self) -> usize {
        self.0.len() // a getter: no `get_` prefix
    }
    pub fn is_admin(&self) -> bool {
        self.0.starts_with("admin")
    }
    pub fn to_display_name(&self) -> String {
        let mut c = self.0.chars(); // allocates a new String
        c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
    }
    pub fn into_string(self) -> String {
        self.0 // consumes self, no copy
    }
}

fn main() {
    let u = Username::new("  AdminAna ");
    println!("{} ({} chars), admin: {}", u.as_str(), u.len(), u.is_admin());
    println!("{}", u.to_display_name());
    let raw: String = u.into_string();
    println!("{raw}");
}
Output
adminana (8 chars), admin: true
Adminana
adminana

A getter for a name field is called name(), and a setter usually becomes either a name_mut() or a method that expresses intent (rename(x)).

2. Accept the most general input you can use

A parameter of &str accepts &String, &str, and string literals. &[T] accepts &Vec<T>, arrays, and slices. Asking for &String or &Vec<T> needlessly restricts callers.

Rust
fn count_vowels(s: &str) -> usize {
    s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()
}

fn average(values: &[f64]) -> Option<f64> {
    (!values.is_empty()).then(|| values.iter().sum::<f64>() / values.len() as f64)
}

fn main() {
    let owned = String::from("Programming in Rust");
    println!("{}", count_vowels(&owned)); // &String -> &str
    println!("{}", count_vowels("literal")); // &'static str
    println!("{}", count_vowels(&owned[..11])); // a slice of a String

    let v = vec![1.0, 2.0, 4.5];
    println!("{:?}", average(&v)); // &Vec<f64> -> &[f64]
    println!("{:?}", average(&[10.0, 20.0])); // an array
    println!("{:?}", average(&v[1..])); // a sub-slice
}
Output
5
3
3
Some(2.5)
Some(15.0)
Some(3.25)

3. impl Into<String> when you need to own the value

If a function stores the string, taking String forces callers with a &str to write .to_string(). impl Into<String> accepts both, and moves an existing String without copying.

Rust
struct Email {
    to: String,
    subject: String,
}

impl Email {
    fn new(to: impl Into<String>, subject: impl Into<String>) -> Email {
        Email { to: to.into(), subject: subject.into() }
    }
}

fn main() {
    let a = Email::new("ana@example.com", "Hi"); // &str: converted
    let subject = format!("Invoice #{}", 42);
    let b = Email::new(String::from("ben@example.com"), subject); // String: moved, no copy
    println!("{} / {}", a.to, a.subject);
    println!("{} / {}", b.to, b.subject);
}
Output
ana@example.com / Hi
ben@example.com / Invoice #42

4. AsRef<Path> for anything path-like

Standard library file functions take impl AsRef<Path>, so they accept &str, String, Path, and PathBuf. Do the same in your own APIs.

Rust
use std::path::Path;

fn extension_of(path: impl AsRef<Path>) -> Option<String> {
    path.as_ref().extension().map(|e| e.to_string_lossy().to_lowercase())
}

fn main() {
    let owned = String::from("report.PDF");
    let buf = std::path::PathBuf::from("/var/log/app.log");
    println!("{:?}", extension_of("photo.jpeg"));
    println!("{:?}", extension_of(&owned));
    println!("{:?}", extension_of(&buf));
    println!("{:?}", extension_of(Path::new("Makefile")));
}
Output
Some("jpeg")
Some("pdf")
Some("log")
None

5. Accept impl IntoIterator

Taking impl IntoIterator<Item = T> accepts a Vec, an array, a HashSet, a range, or a lazy iterator chain, without making the caller build a Vec first.

Rust
use std::collections::BTreeSet;

fn total_cents(prices: impl IntoIterator<Item = u32>) -> u64 {
    prices.into_iter().map(u64::from).sum()
}

fn main() {
    println!("{}", total_cents(vec![199, 250]));
    println!("{}", total_cents([100, 200, 300]));
    println!("{}", total_cents(BTreeSet::from([5, 10])));
    println!("{}", total_cents((1..=4).map(|n| n * 100))); // a lazy chain, no Vec
}
Output
449
600
15
1000

6. Return iterators instead of Vecs

Returning impl Iterator<Item = T> lets callers decide: collect into any collection, stop early, or chain more adapters, with no intermediate allocation.

Rust
struct Inventory {
    items: Vec<(String, u32)>,
}

impl Inventory {
    fn low_stock(&self, below: u32) -> impl Iterator<Item = &str> {
        self.items.iter().filter(move |(_, qty)| *qty < below).map(|(name, _)| name.as_str())
    }
}

fn main() {
    let inv = Inventory { items: vec![("pens".into(), 3), ("paper".into(), 120), ("ink".into(), 1), ("tape".into(), 9)] };

    let first = inv.low_stock(10).next(); // stops after the first match
    let count = inv.low_stock(10).count();
    let joined = inv.low_stock(5).collect::<Vec<_>>().join(", ");
    println!("{first:?}, {count} low, critically low: {joined}");
}
Output
Some("pens"), 3 low, critically low: pens, ink

Part 2: Types that prevent mistakes

7. Newtypes stop unit mix-ups

Two f64s can be swapped by accident; a Meters and a Feet can't. A newtype (a one-field tuple struct) costs nothing at runtime and turns mix-ups into compile errors.

Rust✗ does not compile · E0308
struct Meters(f64);
struct Feet(f64);

fn climb_rate(height: Meters, seconds: f64) -> f64 {
    height.0 / seconds
}

fn main() {
    let altitude = Feet(3000.0);
    println!("{}", climb_rate(altitude, 60.0));
}
Output
error[E0308]: mismatched types
  --> src/main.rs:10:31
   |
10 |     println!("{}", climb_rate(altitude, 60.0));
   |                    ---------- ^^^^^^^^ expected `Meters`, found `Feet`
   |                    |
   |                    arguments to this function are incorrect
   |
note: function defined here
  --> src/main.rs:4:4
   |
 4 | fn climb_rate(height: Meters, seconds: f64) -> f64 {
   |    ^^^^^^^^^^ --------------

The famous example of what this prevents is NASA's Mars Climate Orbiter, lost in 1999 when one team's software produced pound-force seconds and another expected newton-seconds.

8. Newtypes with conversions and operators

Give a newtype the conversions and operators that make sense, and nothing else. Here Meters can be added and compared, and converted from Feet explicitly.

Rust
use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Meters(f64);

#[derive(Debug, Clone, Copy)]
struct Feet(f64);

impl From<Feet> for Meters {
    fn from(f: Feet) -> Meters {
        Meters(f.0 * 0.3048)
    }
}

impl Add for Meters {
    type Output = Meters;
    fn add(self, other: Meters) -> Meters {
        Meters(self.0 + other.0)
    }
}

fn main() {
    let runway = Meters(2500.0);
    let extension: Meters = Feet(1000.0).into(); // explicit conversion
    let total = runway + extension;
    println!("{total:?}");
    println!("long enough for a 747: {}", total >= Meters(2800.0));
}
Output
Meters(2804.8)
long enough for a 747: true

9. Parse, don't validate

Instead of a validate(email: &str) -> bool that callers might forget to call, give the validated value its own type, created only by a parsing function. Every Email in the program is then valid by construction, and functions that take an Email never re-check.

Rust
#[derive(Debug, Clone)]
pub struct Email(String);

impl Email {
    pub fn parse(raw: &str) -> Result<Email, String> {
        let raw = raw.trim();
        let (user, domain) = raw.split_once('@').ok_or(format!("{raw:?} has no @"))?;
        if user.is_empty() || !domain.contains('.') || domain.starts_with('.') {
            return Err(format!("{raw:?} is not a valid address"));
        }
        Ok(Email(raw.to_lowercase()))
    }

    pub fn domain(&self) -> &str {
        self.0.split_once('@').map(|(_, d)| d).unwrap() // can't fail: checked in parse
    }
}

// No validation needed here: the type guarantees it.
fn send_welcome(to: &Email) -> String {
    format!("welcome mail queued for {} (domain {})", to.0, to.domain())
}

fn main() {
    for raw in ["Ana@Example.com", "no-at-sign", "x@.com", "ben@mail.org"] {
        match Email::parse(raw) {
            Ok(email) => println!("{}", send_welcome(&email)),
            Err(e) => println!("rejected: {e}"),
        }
    }
}
Output
welcome mail queued for ana@example.com (domain example.com)
rejected: "no-at-sign" has no @
rejected: "x@.com" is not a valid address
welcome mail queued for ben@mail.org (domain mail.org)

10. Make illegal states unrepresentable

A struct with connected: bool, socket: Option<Socket> and error: Option<String> fields can be in contradictory states (connected with an error, disconnected with a socket). An enum where each state carries exactly its own data can't.

Rust
#[derive(Debug)]
enum Connection {
    Disconnected,
    Connecting { attempt: u32 },
    Connected { session_id: u64 },
    Failed { reason: String },
}

impl Connection {
    fn step(self, ok: bool) -> Connection {
        match self {
            Connection::Disconnected => Connection::Connecting { attempt: 1 },
            Connection::Connecting { attempt } if ok => Connection::Connected { session_id: 1000 + attempt as u64 },
            Connection::Connecting { attempt } if attempt < 3 => Connection::Connecting { attempt: attempt + 1 },
            Connection::Connecting { .. } => Connection::Failed { reason: String::from("3 attempts failed") },
            done => done, // Connected and Failed stay as they are
        }
    }
}

fn main() {
    let mut c = Connection::Disconnected;
    for ok in [false, false, true, false] {
        c = c.step(ok);
        println!("{c:?}");
    }
    // There's no way to build "Connected with a failure reason".
}
Output
Connecting { attempt: 1 }
Connecting { attempt: 2 }
Connected { session_id: 1002 }
Connected { session_id: 1002 }

11. Typestate: states checked at compile time

The enum above checks transitions at runtime. When the order of operations is fixed, you can go further and make each state a type, so calling a method in the wrong state doesn't compile. A generic marker parameter keeps it to one struct.

Rust
use std::marker::PhantomData;

struct Draft;
struct Reviewed;
struct Published;

struct Article<State> {
    title: String,
    body: String,
    _state: PhantomData<State>,
}

impl Article<Draft> {
    fn new(title: &str) -> Self {
        Article { title: title.to_string(), body: String::new(), _state: PhantomData }
    }
    fn write(mut self, text: &str) -> Self {
        self.body.push_str(text);
        self
    }
    fn submit(self) -> Article<Reviewed> {
        Article { title: self.title, body: self.body, _state: PhantomData }
    }
}

impl Article<Reviewed> {
    fn publish(self) -> Article<Published> {
        Article { title: self.title, body: self.body, _state: PhantomData }
    }
}

impl Article<Published> {
    fn url(&self) -> String {
        format!("/posts/{}", self.title.to_lowercase().replace(' ', "-"))
    }
}

fn main() {
    let draft = Article::new("Typestate in Rust").write("States as types...");
    // draft.publish(); // error: no method `publish` on Article<Draft>
    let live = draft.submit().publish();
    println!("{} ({} chars) at {}", live.title, live.body.len(), live.url());
}
Output
Typestate in Rust (18 chars) at /posts/typestate-in-rust

The Methods lesson built this with separate structs; the generic version shares fields and common methods across states.

12. Using a method in the wrong state is a compile error

This is the payoff: the invalid sequence is rejected before the program runs.

Rust✗ does not compile · E0599
use std::marker::PhantomData;

struct Draft;
struct Published;

struct Article<State> {
    title: String,
    _state: PhantomData<State>,
}

impl Article<Draft> {
    fn new(title: &str) -> Self {
        Article { title: title.to_string(), _state: PhantomData }
    }
}

impl Article<Published> {
    fn url(&self) -> String {
        format!("/posts/{}", self.title)
    }
}

fn main() {
    let draft = Article::new("oops");
    println!("{}", draft.url());
}
Output
error[E0599]: no method named `url` found for struct `Article<Draft>` in the current scope
  --> src/main.rs:25:26
   |
 6 | struct Article<State> {
   | --------------------- method `url` not found for this struct
...
25 |     println!("{}", draft.url());
   |                          ^^^ method not found in `Article<Draft>`
   |
   = note: the method was found for
           - `Article<Published>`

13. Default and struct update syntax

For types with many optional settings, deriving Default and letting callers override fields with ..Default::default() is often all you need, with no builder at all.

Rust
#[derive(Debug, Clone)]
struct ServerConfig {
    host: String,
    port: u16,
    workers: usize,
    tls: bool,
    max_body_kb: u32,
}

impl Default for ServerConfig {
    fn default() -> Self {
        ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, tls: false, max_body_kb: 1024 }
    }
}

fn main() {
    let dev = ServerConfig::default();
    let prod = ServerConfig { host: String::from("0.0.0.0"), port: 443, tls: true, ..Default::default() };
    println!("{dev:?}");
    println!("{prod:?}");
}
Output
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, tls: false, max_body_kb: 1024 }
ServerConfig { host: "0.0.0.0", port: 443, workers: 4, tls: true, max_body_kb: 1024 }

#[derive(Default)] works when every field's own default is right (0, false, empty string); enums pick their default with a #[default] attribute on one variant.

14. The builder pattern

When construction needs validation, or has required and optional parts, a builder makes call sites readable. This one takes self by value in each setter (a consuming builder) and validates in build().

Rust
#[derive(Debug)]
pub struct HttpRequest {
    method: String,
    url: String,
    headers: Vec<(String, String)>,
    timeout_ms: u64,
}

#[derive(Default)]
pub struct HttpRequestBuilder {
    method: Option<String>,
    url: Option<String>,
    headers: Vec<(String, String)>,
    timeout_ms: Option<u64>,
}

impl HttpRequest {
    pub fn builder() -> HttpRequestBuilder {
        HttpRequestBuilder::default()
    }
}

impl HttpRequestBuilder {
    pub fn get(mut self, url: impl Into<String>) -> Self {
        self.method = Some(String::from("GET"));
        self.url = Some(url.into());
        self
    }
    pub fn header(mut self, name: &str, value: &str) -> Self {
        self.headers.push((name.to_string(), value.to_string()));
        self
    }
    pub fn timeout_ms(mut self, ms: u64) -> Self {
        self.timeout_ms = Some(ms);
        self
    }
    pub fn build(self) -> Result<HttpRequest, String> {
        let url = self.url.ok_or("url is required")?;
        if !url.starts_with("http") {
            return Err(format!("invalid url {url:?}"));
        }
        Ok(HttpRequest { method: self.method.unwrap_or_else(|| String::from("GET")), url, headers: self.headers, timeout_ms: self.timeout_ms.unwrap_or(30_000) })
    }
}

fn main() {
    let req = HttpRequest::builder().get("https://api.example.com/users").header("Accept", "application/json").timeout_ms(5_000).build();
    println!("{req:#?}");
    println!("{:?}", HttpRequest::builder().header("X", "1").build());
    println!("{:?}", HttpRequest::builder().get("ftp://old").build());
}
Output
Ok(
    HttpRequest {
        method: "GET",
        url: "https://api.example.com/users",
        headers: [
            (
                "Accept",
                "application/json",
            ),
        ],
        timeout_ms: 5000,
    },
)
Err("url is required")
Err("invalid url \"ftp://old\"")

15. A builder with compile-time required fields

Combine the builder with typestate and forgetting a required field becomes a compile error instead of a runtime Err. The builder's type records which required fields have been set.

Rust
use std::marker::PhantomData;

struct Missing;
struct Set;

struct EmailBuilder<To, Subject> {
    to: Option<String>,
    subject: Option<String>,
    body: String,
    _state: PhantomData<(To, Subject)>,
}

struct Email {
    to: String,
    subject: String,
    body: String,
}

impl EmailBuilder<Missing, Missing> {
    fn new() -> Self {
        EmailBuilder { to: None, subject: None, body: String::new(), _state: PhantomData }
    }
}

impl<S> EmailBuilder<Missing, S> {
    fn to(self, addr: &str) -> EmailBuilder<Set, S> {
        EmailBuilder { to: Some(addr.into()), subject: self.subject, body: self.body, _state: PhantomData }
    }
}

impl<T> EmailBuilder<T, Missing> {
    fn subject(self, s: &str) -> EmailBuilder<T, Set> {
        EmailBuilder { to: self.to, subject: Some(s.into()), body: self.body, _state: PhantomData }
    }
}

impl<T, S> EmailBuilder<T, S> {
    fn body(mut self, text: &str) -> Self {
        self.body.push_str(text); // optional, allowed in any state
        self
    }
}

impl EmailBuilder<Set, Set> {
    // `build` only exists once both required fields are set; it can't fail.
    fn build(self) -> Email {
        Email { to: self.to.unwrap(), subject: self.subject.unwrap(), body: self.body }
    }
}

fn main() {
    let mail = EmailBuilder::new().subject("Your order shipped").body("Tracking: 1Z999").to("ana@example.com").build();
    println!("to {} | {} | {}", mail.to, mail.subject, mail.body);
    // EmailBuilder::new().to("x@y.z").build(); // error: `build` not found for EmailBuilder<Set, Missing>
}
Output
to ana@example.com | Your order shipped | Tracking: 1Z999

Crates like bon and typed-builder generate this pattern from a derive, with the checks at compile time.

16. Enums instead of boolean parameters

copy_file("a", "b", true, false) is unreadable at the call site. Two-variant enums name the options and can't be passed in the wrong order.

Rust
#[derive(Debug, Clone, Copy, PartialEq)]
enum Overwrite {
    Yes,
    No,
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum Permissions {
    Preserve,
    Reset,
}

fn copy_file(from: &str, to: &str, overwrite: Overwrite, perms: Permissions) -> String {
    let exists = to.ends_with(".bak");
    if exists && overwrite == Overwrite::No {
        return format!("skipped {to}: already exists");
    }
    format!("copied {from} -> {to} ({})", if perms == Permissions::Preserve { "permissions kept" } else { "default permissions" })
}

fn main() {
    println!("{}", copy_file("db.sqlite", "db.sqlite.bak", Overwrite::No, Permissions::Preserve));
    println!("{}", copy_file("db.sqlite", "db.sqlite.bak", Overwrite::Yes, Permissions::Reset));
}
Output
skipped db.sqlite.bak: already exists
copied db.sqlite -> db.sqlite.bak (default permissions)

Part 3: Playing well with the standard library

17. Implement the common traits eagerly

Users expect to print, compare, hash, clone, and sort your types. Derive the standard traits whenever they make sense; a public type without Debug is especially frustrating to work with.

Rust
use std::collections::{BTreeSet, HashMap};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum Priority {
    Low,
    Medium,
    High,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct Ticket {
    priority: Priority, // derived Ord compares fields top to bottom
    id: u32,
}

fn main() {
    let tickets = vec![
        Ticket { priority: Priority::Low, id: 3 },
        Ticket { priority: Priority::High, id: 7 },
        Ticket { priority: Priority::High, id: 2 },
    ];

    let sorted: BTreeSet<Ticket> = tickets.iter().cloned().collect(); // needs Ord
    println!("{:?}", sorted.iter().map(|t| t.id).collect::<Vec<_>>());

    let mut per_priority: HashMap<Priority, usize> = HashMap::new(); // needs Hash + Eq
    for t in &tickets {
        *per_priority.entry(t.priority).or_default() += 1;
    }
    println!("high: {}", per_priority[&Priority::High]);
    println!("max: {:?}", tickets.iter().max()); // needs Ord
}
Output
[3, 2, 7]
high: 2
max: Some(Ticket { priority: High, id: 7 })

The recommended set for most public types: Debug, Clone, PartialEq, and where they apply, Eq, Hash, PartialOrd, Ord, Default, and Copy (for small plain-data types).

18. Display for users, Debug for developers

Debug shows the structure, for logs and debugging. Display is the human-facing format, and it gives you to_string() for free.

Rust
use std::fmt;

#[derive(Debug)]
struct Money {
    cents: i64,
    currency: &'static str,
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let sign = if self.cents < 0 { "-" } else { "" };
        let abs = self.cents.abs();
        write!(f, "{sign}{}{}.{:02}", self.currency, abs / 100, abs % 100)
    }
}

fn main() {
    let refund = Money { cents: -1250, currency: "$" };
    println!("{refund}");
    println!("{refund:?}");
    let label: String = refund.to_string(); // from Display
    println!("{label:>12}|"); // padding applies to the whole formatted value
}
Output
-$12.50
Money { cents: -1250, currency: "$" }
     -$12.50|

19. FromStr: parsing with .parse()

Implementing FromStr lets callers write "...".parse::<YourType>(), the same way they parse numbers. It pairs naturally with Display for round-tripping text.

Rust
use std::fmt;
use std::str::FromStr;

#[derive(Debug, PartialEq, Clone, Copy)]
struct Version {
    major: u32,
    minor: u32,
    patch: u32,
}

impl FromStr for Version {
    type Err = String;

    fn from_str(s: &str) -> Result<Version, String> {
        let parts: Vec<&str> = s.trim_start_matches('v').split('.').collect();
        let [major, minor, patch] = parts.as_slice() else {
            return Err(format!("{s:?}: expected MAJOR.MINOR.PATCH"));
        };
        let num = |p: &str| p.parse::<u32>().map_err(|_| format!("{s:?}: {p:?} is not a number"));
        Ok(Version { major: num(major)?, minor: num(minor)?, patch: num(patch)? })
    }
}

impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

fn main() {
    let v: Version = "v1.42.3".parse().unwrap();
    println!("{v} ({v:?})");
    println!("round trip: {}", v.to_string().parse::<Version>() == Ok(v));
    println!("{:?}", "1.2".parse::<Version>());
    println!("{:?}", "1.x.0".parse::<Version>());
}
Output
1.42.3 (Version { major: 1, minor: 42, patch: 3 })
round trip: true
Err("\"1.2\": expected MAJOR.MINOR.PATCH")
Err("\"1.x.0\": \"x\" is not a number")

20. From and TryFrom for conversions

From<A> for B is an infallible conversion and gives you .into() for free; TryFrom is the fallible version with .try_into(). Implementing them is how your types join the standard conversion vocabulary, including ?'s error conversion.

Rust
use std::convert::TryFrom;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Percent(u8);

impl TryFrom<i32> for Percent {
    type Error = String;
    fn try_from(v: i32) -> Result<Percent, String> {
        u8::try_from(v).ok().filter(|p| *p <= 100).map(Percent).ok_or(format!("{v} is not a percentage"))
    }
}

impl From<Percent> for f64 {
    fn from(p: Percent) -> f64 {
        p.0 as f64 / 100.0
    }
}

fn discounted(price: f64, off: Percent) -> f64 {
    price * (1.0 - f64::from(off))
}

fn main() {
    let off: Percent = 25.try_into().unwrap();
    println!("{:.2}", discounted(80.0, off));
    println!("{:?} {:?}", Percent::try_from(150), Percent::try_from(-3));
    let as_fraction: f64 = off.into();
    println!("{as_fraction}");
}
Output
60.00
Err("150 is not a percentage") Err("-3 is not a percentage")
0.25

21. Make your collection iterable

For for x in &collection to work, implement IntoIterator for &YourType. Returning the inner collection's iterator is usually enough. Add iter() too, since users expect it.

Rust
struct Playlist {
    name: String,
    songs: Vec<String>,
}

impl Playlist {
    fn iter(&self) -> std::slice::Iter<'_, String> {
        self.songs.iter()
    }
}

impl<'a> IntoIterator for &'a Playlist {
    type Item = &'a String;
    type IntoIter = std::slice::Iter<'a, String>;
    fn into_iter(self) -> Self::IntoIter {
        self.songs.iter()
    }
}

impl IntoIterator for Playlist {
    type Item = String;
    type IntoIter = std::vec::IntoIter<String>;
    fn into_iter(self) -> Self::IntoIter {
        self.songs.into_iter()
    }
}

fn main() {
    let p = Playlist { name: String::from("focus"), songs: vec!["Intro".into(), "Deep Work".into(), "Outro".into()] };
    for song in &p {
        print!("{song}; ");
    }
    println!("({} has {} songs)", p.name, p.iter().count());
    let owned: Vec<String> = p.into_iter().filter(|s| s.len() > 5).collect(); // consumes the playlist
    println!("{owned:?}");
}
Output
Intro; Deep Work; Outro; (focus has 3 songs)
["Deep Work"]

22. FromIterator and Extend: collect into your type

Implementing FromIterator lets users collect() straight into your type; Extend lets them add items from any iterator. Your collection then behaves like the built-in ones.

Rust
use std::collections::BTreeMap;

#[derive(Debug, Default)]
struct Histogram {
    buckets: BTreeMap<u32, usize>, // bucket start -> count
    width: u32,
}

impl Histogram {
    fn add(&mut self, value: u32) {
        let w = if self.width == 0 { 10 } else { self.width };
        *self.buckets.entry(value / w * w).or_default() += 1;
    }
}

impl FromIterator<u32> for Histogram {
    fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
        let mut h = Histogram { width: 10, ..Default::default() };
        h.extend(iter);
        h
    }
}

impl Extend<u32> for Histogram {
    fn extend<I: IntoIterator<Item = u32>>(&mut self, iter: I) {
        for v in iter {
            self.add(v);
        }
    }
}

fn main() {
    let latencies = [12, 15, 27, 31, 8, 22, 45, 19];
    let mut h: Histogram = latencies.into_iter().collect();
    h.extend([41, 44, 49]);
    for (start, n) in &h.buckets {
        println!("{start:>2}-{:<2} {}", start + h.width - 1, "#".repeat(*n));
    }
}
Output
 0-9  #
10-19 ###
20-29 ##
30-39 #
40-49 ####

23. Extension traits: adding methods to types you don't own

You can't add methods to str or Vec directly, but you can define a trait with the methods you want and implement it for them. Users opt in by importing the trait.

Rust
pub trait StrExt {
    fn truncate_words(&self, max_words: usize) -> String;
    fn is_blank(&self) -> bool;
}

impl StrExt for str {
    fn truncate_words(&self, max_words: usize) -> String {
        let words: Vec<&str> = self.split_whitespace().collect();
        if words.len() <= max_words {
            words.join(" ")
        } else {
            format!("{}...", words[..max_words].join(" "))
        }
    }

    fn is_blank(&self) -> bool {
        self.trim().is_empty()
    }
}

fn main() {
    let summary = "Rust gives you control over memory without a garbage collector";
    println!("{}", summary.truncate_words(5));
    println!("{}", "short text".truncate_words(5));
    println!("{} {}", "   ".is_blank(), String::from(" x ").is_blank()); // String derefs to str
}
Output
Rust gives you control over...
short text
true false

The standard library and popular crates use this everywhere: Read/Write, Iterator adapters from itertools, StreamExt, FutureExt.

24. Callbacks: impl Fn, generics, or Box<dyn Fn>

How you accept a closure depends on what you do with it. Call it right away: impl Fn. Store it in a struct: a generic parameter (fast) or Box<dyn Fn> (flexible, allows a list of different closures).

Rust
// Called immediately: impl Fn.
fn retry_count(attempts: u32, op: impl Fn(u32) -> bool) -> Option<u32> {
    (1..=attempts).find(|&n| op(n))
}

// Stored, one closure type per struct: a generic.
struct Validator<F: Fn(&str) -> bool> {
    name: &'static str,
    check: F,
}

// Stored, many different closures: Box<dyn Fn>.
struct Hooks {
    on_save: Vec<Box<dyn Fn(&str) -> String>>,
}

fn main() {
    println!("{:?}", retry_count(5, |n| n * n > 10));

    let v = Validator { name: "no-spaces", check: |s: &str| !s.contains(' ') };
    println!("{}: {}", v.name, (v.check)("hello world"));

    let prefix = String::from("[audit]");
    let hooks = Hooks {
        on_save: vec![Box::new(|doc| format!("indexed {doc}")), Box::new(move |doc| format!("{prefix} saved {doc}"))],
    };
    for h in &hooks.on_save {
        println!("{}", h("report.md"));
    }
}
Output
Some(4)
no-spaces: false
indexed report.md
[audit] saved report.md

25. Getters that return Option<&T>, not &Option<T>

Return Option<&T> from getters, via as_ref() or as_deref(). It's easier to use (if let Some(x)), hides how you store the value, and works with ?.

Rust
struct Profile {
    nickname: Option<String>,
    avatar_urls: Option<Vec<String>>,
}

impl Profile {
    fn nickname(&self) -> Option<&str> {
        self.nickname.as_deref() // Option<String> -> Option<&str>
    }
    fn first_avatar(&self) -> Option<&str> {
        self.avatar_urls.as_ref()?.first().map(String::as_str)
    }
}

fn main() {
    let a = Profile { nickname: Some(String::from("ana_dev")), avatar_urls: Some(vec![String::from("/a.png")]) };
    let b = Profile { nickname: None, avatar_urls: None };
    for p in [&a, &b] {
        println!("{} / {}", p.nickname().unwrap_or("anonymous"), p.first_avatar().unwrap_or("/default.png"));
    }
}
Output
ana_dev / /a.png
anonymous / /default.png

26. Evolving an API without breaking users

Public APIs change. A few habits keep changes non-breaking:

Habit Why
keep struct fields private; offer constructors and getters you can add or change fields freely
#[non_exhaustive] on public enums and structs you can add variants or fields in a minor version
sealed traits for traits users shouldn't implement you can add methods to them later
accept impl Into<X> / impl AsRef<X> you can widen accepted types later
return impl Iterator instead of a concrete iterator type you can change the implementation
#[deprecated(since, note)] before removing things users get a warning and a migration hint

The Crates & Workspaces and Modules lessons show #[non_exhaustive], #[deprecated], and sealed traits in detail. Tools like cargo-semver-checks catch accidental breaking changes before you publish.

27. Realistic: a Money type that's hard to misuse

Bringing several idioms together: a newtype with private fields, parsing via FromStr, a Display for users, checked arithmetic that refuses to mix currencies, and Sum so a list of amounts can be totaled with .sum().

Rust
use std::fmt;
use std::iter::Sum;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Currency {
    Eur,
    Usd,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Money {
    cents: i64,
    currency: Currency,
}

impl Money {
    pub fn zero(currency: Currency) -> Money {
        Money { cents: 0, currency }
    }

    pub fn checked_add(self, other: Money) -> Result<Money, String> {
        if self.currency != other.currency {
            return Err(format!("cannot add {} to {}", other, self));
        }
        Ok(Money { cents: self.cents + other.cents, currency: self.currency })
    }
}

impl FromStr for Money {
    type Err = String;
    fn from_str(s: &str) -> Result<Money, String> {
        let (amount, code) = s.trim().rsplit_once(' ').ok_or(format!("{s:?}: expected e.g. \"12.50 EUR\""))?;
        let currency = match code {
            "EUR" => Currency::Eur,
            "USD" => Currency::Usd,
            other => return Err(format!("unknown currency {other}")),
        };
        let (whole, frac) = amount.split_once('.').unwrap_or((amount, "0"));
        let frac = format!("{frac:0<2}");
        let bad_amount = |_| format!("{s:?}: invalid amount {amount:?}");
        let cents = whole.parse::<i64>().map_err(bad_amount)? * 100 + frac[..2].parse::<i64>().map_err(bad_amount)?;
        Ok(Money { cents, currency })
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let code = match self.currency {
            Currency::Eur => "EUR",
            Currency::Usd => "USD",
        };
        write!(f, "{}.{:02} {code}", self.cents / 100, self.cents % 100)
    }
}

/// Sums amounts of one currency. Panics on mixed currencies; use `checked_add` to handle that case.
impl Sum for Money {
    fn sum<I: Iterator<Item = Money>>(mut iter: I) -> Money {
        let first = iter.next().unwrap_or(Money::zero(Currency::Eur));
        iter.fold(first, |acc, m| acc.checked_add(m).expect("mixed currencies"))
    }
}

fn main() {
    let lines: Vec<Money> = ["12.50 EUR", "3.5 EUR", "100 EUR"].iter().map(|s| s.parse().unwrap()).collect();
    let total: Money = lines.iter().copied().sum();
    println!("total: {total}");

    let usd: Money = "9.99 USD".parse().unwrap();
    println!("{:?}", total.checked_add(usd));
    println!("{:?}", "12,50 EUR".parse::<Money>());
    println!("{:?}", "5 GBP".parse::<Money>());
}
Output
total: 116.00 EUR
Err("cannot add 9.99 USD to 116.00 EUR")
Err("\"12,50 EUR\": invalid amount \"12,50\"")
Err("unknown currency GBP")

28. Realistic: a small client API

A client for a hypothetical REST service, using most of the lesson: a builder with sensible defaults, impl Into<String> parameters, a newtype id, an error enum with Display, Option<&T> getters, and a method returning an iterator.

Rust
use std::collections::HashMap;
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProjectId(u32);

#[derive(Debug)]
pub struct Project {
    id: ProjectId,
    name: String,
    owner: Option<String>,
}

impl Project {
    pub fn id(&self) -> ProjectId {
        self.id
    }
    pub fn name(&self) -> &str {
        &self.name
    }
    pub fn owner(&self) -> Option<&str> {
        self.owner.as_deref()
    }
}

#[derive(Debug)]
pub enum ClientError {
    NotFound(ProjectId),
    Unauthorized,
}

impl fmt::Display for ClientError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ClientError::NotFound(id) => write!(f, "project {} not found", id.0),
            ClientError::Unauthorized => write!(f, "missing or invalid API token"),
        }
    }
}

impl std::error::Error for ClientError {}

pub struct Client {
    token: Option<String>,
    base_url: String,
    projects: HashMap<ProjectId, Project>, // stands in for the remote service
}

pub struct ClientBuilder {
    token: Option<String>,
    base_url: String,
}

impl Client {
    pub fn builder() -> ClientBuilder {
        ClientBuilder { token: None, base_url: String::from("https://api.example.com") }
    }

    pub fn project(&self, id: ProjectId) -> Result<&Project, ClientError> {
        self.token.as_ref().ok_or(ClientError::Unauthorized)?;
        self.projects.get(&id).ok_or(ClientError::NotFound(id))
    }

    pub fn projects_owned_by<'a>(&'a self, owner: &'a str) -> impl Iterator<Item = &'a Project> + 'a {
        self.projects.values().filter(move |p| p.owner() == Some(owner))
    }
}

impl ClientBuilder {
    pub fn token(mut self, t: impl Into<String>) -> Self {
        self.token = Some(t.into());
        self
    }
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into();
        self
    }
    pub fn build(self) -> Client {
        let projects = [(1, "website", Some("ana")), (2, "mobile-app", Some("ben")), (3, "docs", Some("ana")), (4, "sandbox", None)]
            .into_iter()
            .map(|(id, name, owner)| (ProjectId(id), Project { id: ProjectId(id), name: name.into(), owner: owner.map(Into::into) }))
            .collect();
        Client { token: self.token, base_url: self.base_url, projects }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder().token("secret-token").build();
    let p = client.project(ProjectId(2))?;
    println!("{} (id {:?}) owned by {:?} via {}", p.name(), p.id(), p.owner(), client.base_url);

    let mut anas: Vec<&str> = client.projects_owned_by("ana").map(Project::name).collect();
    anas.sort();
    println!("ana owns {anas:?}");

    println!("{}", client.project(ProjectId(9)).unwrap_err());
    let anonymous = Client::builder().base_url("http://localhost:8080").build();
    println!("{}", anonymous.project(ProjectId(1)).unwrap_err());
    Ok(())
}
Output
mobile-app (id ProjectId(2)) owned by Some("ben") via https://api.example.com
ana owns ["docs", "website"]
project 9 not found
missing or invalid API token

Summary

  • Follow the naming conventions (as_/to_/into_, getters without get_, is_, iter/iter_mut/into_iter, new/with_/from_) so callers can predict cost and ownership.
  • Accept general inputs (&str, &[T], impl AsRef<Path>, impl Into<String>, impl IntoIterator) and return impl Iterator rather than building Vecs.
  • Put rules in types: newtypes for units and ids, parse-don't-validate constructors, enums whose variants carry exactly their data, and typestate for fixed sequences (including builders whose build only exists when required fields are set).
  • Use Default plus struct update syntax for simple configuration; builders when construction needs validation.
  • Implement the standard traits users expect: Debug, Clone, PartialEq/Eq/Hash/Ord, Default, Display, FromStr, From/TryFrom, IntoIterator, FromIterator, Extend, and Sum where they fit.
  • Extension traits add methods to foreign types; enums beat boolean flags; getters return Option<&T>.
  • Plan for evolution: private fields, #[non_exhaustive], sealed traits, and #[deprecated].

This completes the Advanced Rust section. Next: How the Rust Compiler Works, the start of the Under the Hood section.