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/Syncwhere 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-checksdetects breaking changes before you publish. Making a type no longerSendis 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)].