We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Macros, FFI and Type-Driven Design step 28 of 28
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 bareString, a barei64, or the two arguments in the wrong order with a type mismatch (E0308). It does not catch swapping two values of the same newtype:fromandtoare bothAccountId, so exchanging them still compiles. - A place for your own methods.
Cents::pluscan be written to saturate. You cannot change what+means fori64itself. - 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 neitherDisplaynorVec<u8>, but afterstruct MyBytes(Vec<u8>);you can writeimpl Display for MyBytes.
It also has costs:
- No inherited methods.
Centshas noabsmethod unless you write one that forwards toi64::abs. - No inherited operators. Operators come from traits such as
Add, and a newtype implements none of its field's traits automatically. Withstruct Meters(f64);,Meters(3.0) + Meters(1.0)is error E0369, "cannot addMeterstoMeters", until you implementAddforMeters.
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:bhas the messagea:b, anderror:has the empty message, rendering asfailed::0. retryanddisconnectmust match exactly.Retry,retry:xandconnectwithout a colon are "anything else".str::parse::<u64>accepts an optional leading+and leading zeros, soconnect:+8andconnect:008both connect with session8, rendered asconnected:8.connect:-1,connect:and values aboveu64::MAXare 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:
urlsets the URL to the value as given (an empty string counts as set).portsets the port if the value parses as au16; otherwise the pair is ignored and the previous port is kept.retriessets the retry count if the value parses as au32; otherwise the pair is ignored.- Any other key, including
URLwith 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:
build_alldoes not compile (E0382). Fix the loop first.transferis missing the positive-amount check.Slug::parseis missing the leading and trailing hyphen rule.replaystill uses the flag struct and twounwrapcalls. Replace it with the enum model.
open_all already works; it is there to show typestate in use.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Rust reference solution
Sign in to track your attempts toward unlocking the reference solution.