Skip to content

← Macros, FFI and Type-Driven Design step 28 of 28

Hard End-to-End

Type-driven design: newtypes, smart constructors, builders, typestate

The capstone. Four techniques for making the compiler enforce your design instead of your documentation.

pub fn transfer(accounts: Vec<(String, i64)>, from: String, to: String, cents: i64) -> Vec<(String, i64)>
pub fn process(raw: Vec<String>) -> Vec<String>
pub fn replay(log: Vec<String>) -> Vec<String>
pub fn build_all(specs: Vec<Vec<(String, String)>>) -> Vec<String>
pub fn open_all(specs: Vec<Vec<(String, String)>>) -> Vec<String>

Everything you have met — newtypes, privacy, enums, generics, PhantomData, Result — turns out to be a tool for one job: making invalid states unrepresentable. This is where you stop solving problems and start designing APIs.


(a) Newtypes

pub struct AccountId(String);
pub struct Cents(i64);

A tuple struct with one field. Zero cost at run time — the wrapper compiles away entirely — and four things gained:

  • Type distinctness. AccountId is not String. Swap the from and to arguments and, if they are typed, the compiler notices.
  • A place to hang impls. Cents::plus can saturate; i64::add cannot be made to.
  • A privacy boundary. A private field means only this module can construct one — the foundation of (b).
  • An orphan-rule escape (7.20). You cannot impl Display for Vec<u8>, but you can impl Display for MyBytes(Vec<u8>).

And two real costs, both of which surprise people:

  • No inherited methods. Cents(3).abs() does not exist. You write the forwarding methods you want.
  • No inherited operators. Meters(3.0) + Meters(1.0) is E0369, “cannot add Meters to Meters“, until you impl Add. Operators come from traits, and a newtype implements none of its inner type’s traits automatically.

Newtypes also inherit their inner type’s limitations. struct Price(f64) with #[derive(Ord, Hash)] fails with three separate E0277s, because f64 implements neither Ord nor Eq nor Hash — and it does not, because NaN != NaN (item 4.11). The newtype cannot paper over that.

The honest caveat, from Alexis King’s Names are not type safety: a newtype with a public field is only documentation. pub struct Cents(pub i64) lets anyone write Cents(-1), so the type carries a name and no guarantee. The guarantee comes from privacy plus a smart constructor, which is (b).

For transfer: wrap at the boundary, then make sure no raw i64 or String flows through the transfer logic itself. Transfers succeed only when the amount is positive, the two accounts differ, both exist, and the source has enough.


(b) Smart constructors — parse, don’t validate

The distinction is one line:

fn validate(s: &str) -> bool                  // returns nothing you can keep
fn parse(s: &str) -> Result<Slug, SlugErr>    // returns EVIDENCE, in the type

validate gives you a boolean that evaporates. Three functions later, nobody remembers whether this string was checked, so it gets checked again — and one path forgets.

parse gives you a Slug. A Slug is a valid slug: if you are holding one, validation happened, and no downstream function needs to re-check. Validation happens once, at the boundary, and the type carries the proof inward.

The mechanism is privacy:

mod domain {
    pub struct Slug(String);          // field is PRIVATE
    impl Slug {
        pub fn parse(s: &str) -> Result<Slug, SlugErr> { ... }
    }
}

Outside mod domain, Slug(x) is E0423 and slug.0 is E0616. The only door in is parse. That is what makes the pattern airtight, and it is why the mod is not optional — a beginner writing a single file will not think to introduce a module, and without one the private field is private to nothing.

Rules for Slug: 1 to 32 bytes, only [a-z0-9-], and no leading or trailing hyphen. process returns the slug or the error’s name.

The limitation, which King himself wrote the follow-up about: a smart constructor gives you validated at construction, not correct by construction. Add pub fn set(&mut self, s: String) that skips validation and the guarantee evaporates instantly. Every mutating method is a new place to preserve the invariant.

And the real cost: every construction site now handles a Result. That is the price, it is paid at the boundary, and it is usually worth it — but it is a price.


(c) Making illegal states unrepresentable

The flagship. Here is a struct with a combinatorial explosion of invalid states:

struct Conn {
    connected: bool,
    session: Option<u64>,
    error: Option<String>,
    retries: u32,
}

Sixteen combinations of the three nullable-ish fields, and most are nonsense. Connected with no session. Connected and holding an error. Not connected but with a session. Nothing prevents any of them, so every read has to defend itself — and defending yourself against a state that cannot happen is exactly what unwrap() is:

format!("connected:{}", state.session.unwrap())   // "it can't be None here"

The refactor is to give each state its own variant carrying exactly the data valid in that state:

enum Conn {
    Idle { retries: u32 },
    Connected { session: u64 },
    Failed { error: String, retries: u32 },
}

Connected has a session and no error. Idle has neither. The Options are gone, the bool is gone, and so is every unwrap — because match gives you the fields that exist in the arm where they exist.

The rule is enforced mechanically. replay carries

#[deny(clippy::unwrap_used, clippy::expect_used)]

so if your model still needs an unwrap, the submission fails. That is the metric: a correct model makes them unnecessary, not merely avoidable.

The event language: connect:<id>, error:<msg>, retry, disconnect. Each event produces the resulting state rendered as idle:<retries>, connected:<id> or failed:<msg>:<retries>. retry only does something from a failed state. Starting state is idle:0.

The tests include interleavings that a boolean-flags model gets wrong — the one to think about is connect, error, connect: with flags, the stale error field survives into the reconnected state.

Two other smells worth recognising once you have the eye for it: Option<Option<T>> (two questions answered by one field), and parallel Vecs that must stay the same length (an invariant nothing enforces — use one Vec of structs).

The real counterpoint, and it deserves to be taken seriously: pushed to the limit, this produces enums with a dozen near-duplicate variants and conversion boilerplate between them. There is a genuine cost curve, and experienced developers stop partway up it. A bool and an Option in a struct is not a crime. Four bools and three Options in a struct is. Clippy’s struct_excessive_bools is the perfect lint for the boundary, and it is allow-by-default for the same reason — you have to decide where your line is.


(d) Builders and typestate

Rust has no optional arguments and no named arguments, so a type with eight configurable fields cannot have a decent constructor. (Clippy’s too_many_arguments fires at seven, which is often exactly the nudge you needed.)

The answer is a builder: chainable setters plus a validating build.

ConfigBuilder::default().url("https://a").port(8080).build()?

The trade-off that is the actual content of this part: the setters take self by value and return Self, which is what makes chaining work. But an owned-self builder cannot be used in a loop without reassignment:

for (k, v) in spec {
    b.port(p);          // E0382: use of moved value `b`
}

b.port(p) moves b. On the second iteration there is nothing left. The fix is b = b.port(p);, threading the value back through — and that is what the starter does not do, which is why it does not compile.

The alternative is fn port(&mut self, v: u16) -> &mut Self, which loops fine and chains worse (you end up needing a final .clone() or a separate build(&self)). Neither style dominates; owned-self is the more common choice in the ecosystem precisely because the chained call site is the one users see most.

Typestate

A builder’s build() returns Result because a required field might be missing. Typestate removes that possibility from the type system instead:

pub struct NoUrl;
pub struct Ready;

pub struct Request<S> { url: String, method: String, _state: PhantomData<S> }

impl Request<NoUrl> {
    pub fn begin() -> Self { ... }
    pub fn url(self, u: &str) -> Request<Ready> { ... }   // <- changes the type
}

impl Request<Ready> {
    pub fn send(self) -> String { ... }                   // <- only exists here
}

send is defined only on Request<Ready>. Calling it too early is not a runtime Err — it is E0599, “no method named send found for struct Request<NoUrl>“. The mistake became a compile error.

PhantomData<S> is there because an unused type parameter is E0392, “parameter S is never used”. A type parameter must appear in a field, and PhantomData is the zero-sized way to say “conceptually it does”.

The core testing problem, stated honestly. Typestate’s entire value is code that fails to compile, and a passing-test harness cannot assert that. The best available proxy is the one used here: Request has no “missing url” error variant at all, and its absence is structurally checkable. open_all still handles a missing url — the input is untrusted strings, so somebody has to — but that check happens once at the boundary, and after it the type system takes over.

And the honest costs, because pretending otherwise would be dishonest:

  • Type signatures get ugly fast; clippy’s type_complexity will find you.
  • Error messages are poor. “no method named send found” does not say “you forgot to set the url”, and there is no great way to make it.
  • It is only worth it when the invariant genuinely matters and the API is used a lot. For a config builder in application code it is usually over-engineering.

In real projects the builder half is #[derive(Builder)] from derive_builder or bonprocedural macros, unavailable here (18.17). Hand-rolling is what you do when you have three fields or when you are writing the derive. Say so out loud rather than letting anyone think this is the everyday norm.


Your job

build_all does not compile — start there and read E0382 carefully, it is the point of part (d). Then transfer is missing a guard, Slug::parse is missing a rule, and replay is still the boolean-soup model with three unwraps under a deny.

Loading visualization…