Frontend Labs
⋯

62. API design: newtypes, typestate, builders, errors, and semver

The Go guide's §54 "Go proverbs, applied" has a Rust counterpart: the Rust API Guidelines. The techniques below are the ones that most often separate a good crate from an awkward one.

62.1 Typestate: invalid call orders do not compile

use std::marker::PhantomData;

struct Disconnected;
struct Connected;

struct Client<State> { addr: String, session: Option<u32>, _state: PhantomData<State> }

impl Client<Disconnected> {
    fn new(addr: &str) -> Self { Client { addr: addr.into(), session: None, _state: PhantomData } }
    fn connect(self) -> Result<Client<Connected>, String> {     // consumes the disconnected client
        if self.addr.is_empty() { return Err("no address".into()); }
        Ok(Client { addr: self.addr, session: Some(7), _state: PhantomData })
    }
}

impl Client<Connected> {
    fn send(&mut self, msg: &str) -> usize { println!("[{}#{:?}] {msg}", self.addr, self.session); msg.len() }
    fn close(self) -> Client<Disconnected> { Client { addr: self.addr, session: None, _state: PhantomData } }
}

fn main() -> Result<(), String> {
    let mut c = Client::new("db:5432").connect()?;
    c.send("SELECT 1");
    let c = c.close();
    // c.send("again");   // error[E0599]: no method named `send` found for `Client<Disconnected>`
    let _ = c;
    Ok(())
}

The states are zero-sized (Ch 57), so the type checking costs nothing at runtime. reqwest::RequestBuilder, the hyper connection builder, and embedded HAL crates (a GPIO pin configured as input has no set_high method) all use this pattern.

62.2 Builders for many optional settings

use std::time::Duration;

#[derive(Debug)]
pub struct ServerConfig { addr: String, max_body: usize, timeout: Duration, tls: bool }

#[derive(Default)]
pub struct ServerConfigBuilder { addr: Option<String>, max_body: Option<usize>, timeout: Option<Duration>, tls: bool }

impl ServerConfigBuilder {
    pub fn addr(mut self, a: impl Into<String>) -> Self { self.addr = Some(a.into()); self }
    pub fn max_body(mut self, n: usize) -> Self { self.max_body = Some(n); self }
    pub fn timeout(mut self, t: Duration) -> Self { self.timeout = Some(t); self }
    pub fn tls(mut self, on: bool) -> Self { self.tls = on; self }
    pub fn build(self) -> Result<ServerConfig, String> {
        let max_body = self.max_body.unwrap_or(1 << 20);
        if max_body > 64 << 20 { return Err("max_body above 64 MiB".into()); } // validate once, here
        Ok(ServerConfig {
            addr: self.addr.ok_or("addr is required")?,
            max_body,
            timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
            tls: self.tls,
        })
    }
}

fn main() {
    let cfg = ServerConfigBuilder::default().addr("0.0.0.0:443").tls(true).build();
    println!("{cfg:?}");
    println!("{:?}", ServerConfigBuilder::default().max_body(1 << 30).build());
}

This plays the same role as Go's functional options (Go §55). If you combine it with typestate (a Builder<NoAddr> without a build method), "addr is required" becomes a compile error instead of a runtime one.

62.3 Design checklist for a public crate

  • Accept general types, return concrete ones: impl AsRef<Path>, impl Into<String>, and &[T] as inputs, concrete types as outputs (the same advice as Go's "accept interfaces, return structs").
  • Errors: one error enum per module or crate, implementing std::error::Error (thiserror), and #[non_exhaustive] so that adding a variant is not a breaking change.
  • #[non_exhaustive] on public structs whose fields may grow, and private fields plus constructors wherever there is an invariant.
  • Implement the common traits (Debug, Clone, PartialEq, Default, Send/Sync where possible). Users cannot add them to your types later, because of the orphan rule.
  • Sealed traits (a public trait with a supertrait in a private module) when you want users to call a trait's methods but not implement it, so you can add methods later.
  • Semver: cargo semver-checks detects breaking changes before you publish. Making a type no longer Send is a breaking change, and so is adding a variant to an exhaustive public enum.
  • Documentation: a runnable example on every public item (doc tests, §23.2), and #![warn(missing_docs)].

Generated from real-life-rust-guide.md · 8 Oct 2026 16:36