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

This exercise combines four techniques for making the compiler, rather than comments and conventions, enforce an API's rules. You implement five functions, one or two per technique:

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>

The common goal is often summarised as making invalid states unrepresentable: choose types so that a value which breaks the rules cannot be constructed, or at least cannot be constructed by accident. Each part below explains one technique, then states the exact contract its function must meet.


(a) Newtypes

A newtype is a tuple struct with one field that wraps an existing type:

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

In practice a one-field struct has the same size as its field, and the optimizer treats it like the field itself, so the wrapper usually costs nothing at run time. The layout and calling convention are only guaranteed identical if you add #[repr(transparent)], which matters for example across FFI. What the wrapper gives you:

  • A distinct type. A function that takes (AccountId, Cents) rejects a bare String, a bare i64, or the two arguments in the wrong order with a type mismatch (E0308). It does not catch swapping two values of the same newtype: from and to are both AccountId, so exchanging them still compiles.
  • A place for your own methods. Cents::plus can be written to saturate. You cannot change what + means for i64 itself.
  • A privacy boundary. If the field is private, only code in the defining module can construct a value. Part (b) builds on this.
  • A way around the orphan rule. Roughly, a trait implementation is allowed only if your crate defines the trait or a type that appears in the implementation (the full rules have more detail). impl Display for Vec<u8> is rejected (E0117) because your crate defines neither Display nor Vec<u8>, but after struct MyBytes(Vec<u8>); you can write impl Display for MyBytes.

It also has costs:

  • No inherited methods. Cents has no abs method unless you write one that forwards to i64::abs.
  • No inherited operators. Operators come from traits such as Add, and a newtype implements none of its field's traits automatically. With struct Meters(f64);, Meters(3.0) + Meters(1.0) is error E0369, "cannot add Meters to Meters", until you implement Add for Meters.

A newtype also cannot add capabilities that its field lacks. f64 implements neither Eq, Ord nor Hash, because NaN is not equal to itself and is not ordered relative to other values (see the float-ordering exercise). So #[derive(Ord, Hash)] struct Price(f64); fails with several E0277 errors, including "the trait bound f64: Ord is not satisfied" and "the trait bound f64: Hash is not satisfied".

A newtype with a public field, such as pub struct Cents(pub i64), gives a distinct name but no guarantee about the value: any code can write Cents(-1). A guarantee needs a private field plus a constructor that checks, which is part (b).

transfer

Move cents from account from to account to and return the whole ledger in its original order. Convert the raw inputs to AccountId and Cents at the start, so the transfer logic itself works only with the newtypes.

The transfer happens only when all of these hold:

  • cents > 0;
  • from != to (names are compared exactly, so "ada" and "Ada" differ);
  • both names appear in accounts (if a name appears more than once, the first occurrence is the one used);
  • the source balance is at least cents.

Otherwise the ledger is returned unchanged. The destination is credited with saturating addition, so a credit that would exceed i64::MAX leaves it at i64::MAX. That keeps the exercise free of overflow panics; a real ledger would refuse such a transfer instead.


(b) Smart constructors: parse, don't validate

A smart constructor is a function that checks its input and returns the wrapped type only if the check passes. The phrase "parse, don't validate", from an essay by Alexis King, describes the difference between these two signatures:

fn validate(s: &str) -> bool                  // the result says nothing about `s` later
fn parse(s: &str) -> Result<Slug, SlugErr>    // the result is a value of a checked type

After validate returns true, you still hold a plain &str. Later code cannot tell from the type whether it was checked, so it either checks again or trusts a convention. After parse returns Ok(slug), you hold a Slug, and if the only way to create a Slug is through parse, every Slug has passed the check. Validation happens once, where outside data enters the program, and functions that take a Slug do not need to repeat it.

Rust enforces "the only way" with privacy, which works per module:

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

Outside mod domain, calling domain::Slug(x) is error E0603 ("tuple struct constructor Slug is private") and reading slug.0 is error E0616 ("field 0 of struct Slug is private"). The module matters: a private field is visible everywhere in the module that defines the struct, including modules nested inside it, so if Slug were defined next to process in the same module, process could still write Slug(anything).

process

For each input string, in order, output the slug itself if Slug::parse accepts it, or the name of the first rule it breaks. The rules are checked in this order:

Check Output on failure
the string is empty empty
it is longer than 32 bytes of UTF-8 too_long
it contains any character other than a-z, 0-9 and - bad_char
it starts or ends with - edge_hyphen

Because the checks run in that order, "-" is edge_hyphen, "A-" is bad_char, and a 33-byte string of uppercase letters is too_long. A non-ASCII character such as é is bad_char (unless the string is already too long). Repeated hyphens inside a slug, as in a--b, are allowed.

A smart constructor guarantees the rule at construction time only. If you later add pub fn set(&mut self, s: String) that stores s without checking it, the guarantee is gone. Every method that can change the value must preserve the rule. The cost of the pattern is that every place that creates a Slug from outside data must handle the Result.


(c) Making illegal states unrepresentable

Here is a connection record built from flags:

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

The first three fields have two shapes each (true/false, Some/None), so there are eight combinations, and most of them are meaningless: connected with no session, connected while holding an error, disconnected with a session. Nothing prevents them, so reading code has to assume one and unwrap:

format!("connected:{}", state.session.unwrap())   // assumes `connected` implies `Some`

The alternative is an enum with one variant per real state, each carrying only the data that makes sense 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. When you match on the enum, each arm can use exactly the fields its variant has, so there is no Option left to unwrap.

replay is annotated with

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

which makes Clippy report any .unwrap() or .expect(...) call inside it as an error. The lint only forbids those two calls; the tests are what check the behaviour. The enum is simply the model in which those calls are not needed.

replay

The connection starts in idle:0. Process the events in order and, after each event, output the current state. Every event produces exactly one output line, including events that change nothing. States render as idle:<retries>, connected:<session> and failed:<message>:<retries>.

Event Effect
connect:<id> If <id> parses as a u64 with str::parse, become connected with that session, from any state (including replacing an existing session). Otherwise nothing changes.
error:<message> Become failed with that message, from any state. The retry count is kept from idle or failed; from connected it is 0.
retry From failed, become idle with the retry count increased by one. From any other state, nothing changes.
disconnect Become idle:0 from any state.
anything else Nothing changes.

Details:

  • The event is split at its first colon only, so error:a:b has the message a:b, and error: has the empty message, rendering as failed::0.
  • retry and disconnect must match exactly. Retry, retry:x and connect without a colon are "anything else".
  • str::parse::<u64> accepts an optional leading + and leading zeros, so connect:+8 and connect:008 both connect with session 8, rendered as connected:8. connect:-1, connect: and values above u64::MAX are ignored.
  • A connected state has no retry count, so connecting discards it.

For example, ["connect:7", "error:boom", "retry", "connect:9"] produces ["connected:7", "failed:boom:0", "idle:1", "connected:9"]. The starter's flag model reports connected:7 for the first three events: setting error does not clear connected, and the rendering checks connected first.

Two other patterns that often signal the same problem: Option<Option<T>>, where one field encodes two separate questions (sometimes deliberate, as in "absent" versus "explicitly null" in an update request), and parallel Vecs that must stay the same length, which a single Vec of structs would enforce.

This approach has a cost too. Taken to the extreme, it produces enums with many similar variants and conversion code between them. A struct with one bool and one Option is often the clearest design. Clippy's struct_excessive_bools lint flags structs with more than three bool fields; it is in the pedantic group, which is not enabled by default, so you choose whether to use it.


(d) Builders and typestate

Rust functions have no optional or named arguments, so a constructor for a type with many settings either takes every setting positionally or needs another design. (Clippy's too_many_arguments lint warns, by default, when a function has more than seven parameters.)

A builder is a separate type with one method per setting and a final build method that checks the result:

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

Here each setter takes self by value and returns Self, which is what lets the calls chain. The trade-off is that each call moves the builder, so in a loop you must store the returned value back:

let b = ConfigBuilder::default();
for port in [80, 8080] {
    b.port(port);       // E0382: use of moved value: `b`
}

b.port(port) consumes b and returns a new builder, which this code discards. The next iteration then uses b again after it was moved. The fix is to make b mutable and write b = b.port(port); so the returned builder replaces the old one. The starter has exactly this bug, which is why it does not compile.

The other common style takes &mut self and returns &mut Self. That works in a loop without reassignment, but a chain started from a temporary, such as ConfigBuilder::default().url("x"), yields a &mut ConfigBuilder, so a build that takes self by value cannot be called at the end of it (E0507, "cannot move out of a mutable reference"). Such builders usually make build take &self and clone the fields it needs. Both styles are in common use.

build_all

Each spec is a list of (key, value) pairs, applied in order:

  • url sets the URL to the value as given (an empty string counts as set).
  • port sets the port if the value parses as a u16; otherwise the pair is ignored and the previous port is kept.
  • retries sets the retry count if the value parses as a u32; otherwise the pair is ignored.
  • Any other key, including URL with different case, is ignored.

A later pair overrides an earlier one with the same key. The port defaults to 443 and retries to 0. Output <url>:<port>:<retries>, or err:missing_url if no url pair appeared. An empty list of specs produces an empty list.

Typestate

A builder's build returns Result because a required setting might be missing. Typestate moves that check to compile time by making the builder's state part of its type:

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 { ... }                   // exists only here
}

send is defined only for Request<Ready>, and the only way to get one is to call url. Calling send on a Request<NoUrl> is not a runtime error; it is compile error E0599, "no method named send found for struct Request<NoUrl> in the current scope".

PhantomData<S> (from std::marker) is needed because a struct must use every type parameter in a field; otherwise the compiler reports E0392, "type parameter S is never used". PhantomData<S> is a zero-sized field that counts as a use.

The tests here run your program and compare its output, so they cannot check that misuse fails to compile. Libraries test that with compile-fail tests, for example doc tests marked compile_fail.

open_all

Each spec is a list of (key, value) pairs. url sets the URL and method sets the method, both as given, with later pairs overriding earlier ones; other keys are ignored. The method defaults to GET. Output <method> <url> (one space between them), or no_url if no url pair appeared. The input is untrusted data, so open_all must still handle a missing URL at run time; it does so once, before building a Request, and from then on the types ensure send is only called on a request that has a URL.

Typestate has costs. Signatures with state parameters get long, and Clippy's type_complexity lint warns about very complex types. The E0599 error names the missing method, not the missing step: it adds a note that the method was found for Request<Ready>, but users of the API still have to work out that calling url is what produces one. It is most useful when breaking the rule would be costly and the API is used in many places.

In larger projects, builders are often generated by derive macros from crates such as derive_builder or bon. Those crates are not available in this exercise, so the builder here is written by hand.


Your job

The starter has one deliberate problem in each part:

  1. build_all does not compile (E0382). Fix the loop first.
  2. transfer is missing the positive-amount check.
  3. Slug::parse is missing the leading and trailing hyphen rule.
  4. replay still uses the flag struct and two unwrap calls. Replace it with the enum model.

open_all already works; it is there to show typestate in use.