Build a Type-Driven API, One Decision at a Time
Rust API design becomes much easier when you stop asking, “Which advanced feature should I use?” and instead ask:
What relationship should the compiler preserve for the caller?
This chapter reconstructs the progress-bar API developed in Will Crichton’s Type-Driven API Design in Rust talk. The point is not the progress bar. The point is the sequence: begin with working concrete code, identify a restriction that is not essential, and remove exactly that restriction. Each step introduces a Rust feature because the API needs it.
The final design combines generics, associated types, trait bounds, blanket implementations, ownership-consuming methods, and typestate. You will not begin with any of those. You will arrive at each one because the previous API has a specific limitation.
The code below is a runnable reconstruction of the presentation’s edits, not a verbatim transcription or the speaker’s original source tree.
How to work through this chapter
Treat this as a coding session, not reference material.
- Create a blank Rust binary called
progress.rs. - Type the current checkpoint into it. Do not paste the final example.
- Run it and confirm its behavior.
- Stop at every Your turn prompt and answer before scrolling farther.
- Make your own smallest change, even if you expect it to fail.
- Read the compiler message before comparing with the next checkpoint.
Every version is intentionally incomplete. Do not “improve” three stages at once: the purpose is to feel which requirement causes which Rust feature to appear.
If you are using Cargo, the loop is:
edit progress.rs → cargo run → read the result → make one design decision
The completed reconstruction lives in examples/type-driven-progress, but do
not open it until the typestate checkpoint.
Checkpoint 1: make the behavior real
The presentation starts with behavior directly inside main. Type this version
before designing an API. The delay is shortened so the exercise stays quick:
use std::{thread::sleep, time::Duration};
const CLEAR: &str = "\x1B[2J\x1B[1;1H";
fn expensive_calculation(_n: &i32) {
sleep(Duration::from_millis(100));
}
fn main() {
let values: Vec<i32> = vec![1, 2, 3];
let mut index: usize = 1;
for value in values.iter() {
println!("{}{}", CLEAR, "*".repeat(index));
index += 1;
expensive_calculation(value);
}
}
It works. That matters. We now have observable behavior to preserve while the representation changes.
The code contains several accidental decisions:
- the collection must be a
Vec; - each element must be an
i32; - the progress state is mixed into
main; - the work and progress rendering are coupled together.
These are not all bad simultaneously. They are simply more specific than the problem requires. Good API evolution removes them one at a time, so every compiler error has a clear cause.
Your turn: Which lines are progress-display machinery rather than the application’s real work? Move only those lines into a function named
progresswhile preserving the output.
Checkpoint 2: extract the first API
The first extracted function remains deliberately concrete and borrows the vector:
use std::{thread::sleep, time::Duration};
const CLEAR: &str = "\x1B[2J\x1B[1;1H";
fn progress(values: &Vec<i32>) {
let mut index: usize = 1;
for value in values.iter() {
println!("{}{}", CLEAR, "*".repeat(index));
index += 1;
expensive_calculation(value);
}
}
fn expensive_calculation(_n: &i32) {
sleep(Duration::from_millis(100));
}
fn main() {
let values: Vec<i32> = vec![1, 2, 3];
progress(&values);
}
The behavior is unchanged, but the caller now has a named operation. Its signature still says exactly what it accepts:
fn progress(values: &Vec<i32>)
Your turn: List every restriction in this signature. Which restriction prevents the same progress logic from accepting a
Vec<String>?
Checkpoint 3: remove the item-type restriction
The progress display never examines an i32. If that was the restriction you
selected, make the smallest possible signature change that also accepts a
Vec<String>.
Your turn: Change only the signature. What symbol will stand for the element type that the caller chooses?
After trying it, compare with this version:
fn progress<T>(values: &Vec<T>) {
for (index, _value) in values.iter().enumerate() {
println!("{}", "*".repeat(index + 1));
}
}
fn main() {
progress(&vec!["parse", "compile", "test"]);
}
T does not mean “some dynamically typed value.” It means that each call has
one concrete element type selected at compile time. Rust normally generates a
specialized version of the generic code for every concrete type used.
Run it with integers and strings. The useful design question is not “Where can I add generics?” It is:
Which facts does this implementation genuinely need to know?
This implementation needs to obtain items in sequence. It does not need to
know their type or that they came from a Vec.
Checkpoint 4: remove the Vec restriction
Now try to call the function with (0..).take(3). It fails because a range
iterator is not a Vec, even though it can provide everything the progress
display needs.
Your turn: Describe the capability the function needs without naming a container. Does it need indexing, insertion, ownership of contiguous memory, or only one item after another?
Many APIs prematurely accept Vec<T> when they only need sequential access.
Rust already has a trait for “produces one value after another”: Iterator.
Change the function to accept a caller-chosen iterator:
fn progress<Iter>(iter: Iter)
where
Iter: Iterator,
{
for (index, _item) in iter.enumerate() {
println!("{}", "*".repeat(index + 1));
}
}
fn main() {
progress([10, 20, 30].into_iter());
progress((0..).take(3));
}
Read the bound as a promise:
Iter: Iterator
means, “the caller may choose any concrete Iter, provided it implements the
Iterator contract.” That contract gives the implementation access to
next() and to the iterator’s item type.
Pause on Iterator::Item
Before looking up the trait, predict what next must return. It cannot return
an item every time because iteration eventually ends.
Your turn: Write the return type of
nextusingOptionand a placeholder item type. Then compare it with the essential portion ofIterator:
#![allow(unused)]
fn main() {
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
}
Item is associated with the iterator implementation. Once Rust knows the
concrete iterator type, it can determine the one type that iterator yields:
std::vec::IntoIter<String>::Item = String
std::ops::Range<u64>::Item = u64
That is different from a generic trait such as Convert<T>, where one type
might implement the same trait several times for different Ts. An associated
type says these facts belong together: this iterator has this item type.
This “keep related facts together” idea will recur throughout the design.
Checkpoint 5: give the loop back to the caller
Our function accepts arbitrary iterators, but it still owns the entire loop. That means the caller cannot naturally perform work on each yielded item.
Your turn: Write the call site you wish existed. Keep the caller’s
forloop and add progress reporting without puttingprocess(item)inside the library.
One desirable call site is:
for item in values.into_iter().progress() {
process(item);
}
The caller owns the work. The library decorates iteration with a side effect.
Your turn: A value used by a
forloop must be iterable. What could our API return that preserves the underlying items while storing an index?
This suggests an iterator adapter: a struct that owns another iterator and
implements Iterator itself. First write only the state it must remember:
#![allow(unused)]
fn main() {
struct Progress<Iter> {
iter: Iter,
index: usize,
}
impl<Iter> Progress<Iter> {
fn new(iter: Iter) -> Self {
Self { iter, index: 0 }
}
}
impl<Iter> Iterator for Progress<Iter>
where
Iter: Iterator,
{
type Item = Iter::Item;
fn next(&mut self) -> Option<Self::Item> {
let item = self.iter.next()?;
self.index += 1;
println!("{}", "*".repeat(self.index));
Some(item)
}
}
}
The key line is:
type Item = Iter::Item;
The wrapper preserves the underlying iterator’s item type. If it wraps an
iterator of String, it is also an iterator of String. The progress layer
changes behavior without changing the values flowing through it.
Notice the ownership model:
Progress<Iter>ownsIter;next(&mut self)mutates the iteration position;- each returned
Iter::Itemis moved out to the caller; - no lifetime parameter is necessary because the wrapper owns the iterator.
An iterator may itself contain references, such as slice::Iter<'a, T>. In
that case the lifetime is already carried inside Iter; Progress<Iter> does
not need to duplicate it.
Checkpoint 6: earn the .progress() syntax
At this point, run:
for item in Progress::new(values.into_iter()) {
process(item);
}
It composes correctly, but the constructor makes the progress implementation
the visual center of the call. We want values.into_iter().progress().
Your turn: Can you write an inherent
impladdingprogressdirectly tostd::vec::IntoIter<T>and every other iterator? Which Rust ownership rule prevents a library from freely adding inherent methods to foreign types?
We cannot add an inherent method to every iterator type: those types belong to the standard library or to callers.
An extension trait adds the method through trait resolution:
trait ProgressIteratorExt: Iterator + Sized {
fn progress(self) -> Progress<Self> {
Progress::new(self)
}
}
impl<Iter> ProgressIteratorExt for Iter
where
Iter: Iterator,
{}
The implementation is blanket: every type satisfying Iterator receives the
extension trait implementation. Sized permits taking self by value and
returning Progress<Self>.
Method-call syntax hides two useful compiler operations:
- Rust finds an in-scope trait that provides
progress. - It verifies that the receiver satisfies that trait’s implementation bounds.
This is why importing an extension trait makes methods appear, and why the method can be unavailable for inappropriate types without a runtime check.
The bound belongs where it improves the caller’s experience:
trait ProgressIteratorExt: Iterator + Sized {
fn progress(self) {}
}
impl<Iter: Iterator> ProgressIteratorExt for Iter {}
fn main() {
// `u32` is not an iterator, so it does not receive iterator extensions.
1_u32.progress();
}
Compile this in your scratch project and inspect the diagnostic.
Checkpoint 7: distinguish bounded from unbounded
The simple display can count completed items, but a bar such as [*** ] also
needs a total. Some iterators know their exact remaining length:
#![allow(unused)]
fn main() {
let finite = [10, 20, 30].into_iter();
assert_eq!(finite.len(), 3);
}
Others do not have a finite bound:
#![allow(unused)]
fn main() {
let unbounded = 0..;
let first_three: Vec<_> = unbounded.take(3).collect();
assert_eq!(first_three, vec![0, 1, 2]);
}
Your turn: Should every
Iteratorbe required to provide a length? Test that idea against0... If not, where should the stronger requirement live: onProgressitself or only on the method that needs it?
The standard library represents “knows its exact length” with
ExactSizeIterator. We expose with_bound only when that capability is
available:
impl<Iter> Progress<Iter>
where
Iter: ExactSizeIterator,
{
fn with_bound(mut self) -> Self {
self.bound = Some(self.iter.len());
self
}
}
This is type-driven API design in a practical form. There is no
if iterator_has_exact_size branch. The method exists for qualifying concrete
types and does not exist for the others.
Two nuances matter:
- A trait bound is a compile-time capability requirement, not an inheritance hierarchy.
ExactSizeIteratoris stronger thanIterator; requiring it everywhere would unnecessarily reject streams and infinite ranges.
The concrete type matters more than what looks finite to a human. For example,
an array’s IntoIter has an exact size. Some inclusive integer ranges do not
implement ExactSizeIterator because their full length cannot always be
represented safely. Let the trait bound express the actual guarantee instead
of guessing from the syntax.
Put the stronger bound on the narrowest API that needs it.
Checkpoint 8: deliberately create a bad API
Suppose bounded progress bars show delimiters:
[*** ]
Add a builder-style method:
progress.with_delimiters(('<', '>'))
Now call it on both a bounded and an unbounded progress iterator. An unbounded display only prints a count or spinner, so delimiters have no visible effect:
(0..).progress().with_delimiters(('<', '>'))
Your turn: Do not reach for typestate yet. Explain the bug from the caller’s perspective, then propose at least two fixes. One may change the rendering behavior rather than the types.
This is an API-design smell: the caller can express a configuration that has no meaning. There are several legitimate fixes:
- Make delimiters meaningful for unbounded output too.
- Return a runtime error for an invalid configuration.
- Make
with_delimitersimplicitly enable bounded display when possible. - Encode bounded versus unbounded state in the type and expose the method only in the bounded state.
The talk chooses option four to demonstrate typestate. That is not proof that typestate is always the best product decision. It is best when the states are few, transitions are clear, and preventing misuse justifies more complex types and compiler messages.
Checkpoint 9: encode the state transition
We will now explore the talk’s typestate solution. First represent the states as distinct types:
#![allow(unused)]
fn main() {
struct Unbounded;
struct Bounded {
len: usize,
delimiters: (char, char),
}
}
Your turn:
Progress<Iter>currently records only the iterator type. Where could it record whether bounded configuration has occurred without a runtimebool?
Add the state as another type parameter:
#![allow(unused)]
fn main() {
struct Progress<Iter, State> {
iter: Iter,
index: usize,
state: State,
}
}
Construction begins in the unbounded state. Write the return type of new
before writing its body:
impl<Iter> Progress<Iter, Unbounded> {
fn new(iter: Iter) -> Self {
Self {
iter,
index: 0,
state: Unbounded,
}
}
}
The crucial transition is with_bound.
Your turn: Fill in only its signature. It starts with
Progress<Iter, Unbounded>and must prove bounded configuration in its return type. Should it take&self,&mut self, orself?
It consumes the old value and returns a value with a different type:
impl<Iter> Progress<Iter, Unbounded>
where
Iter: ExactSizeIterator,
{
fn with_bound(self) -> Progress<Iter, Bounded> {
let len = self.iter.len();
Progress {
iter: self.iter,
index: self.index,
state: Bounded {
len,
delimiters: ('[', ']'),
},
}
}
}
The transition consumes self because the implementation must move iter
from the old wrapper into the new wrapper. The old unbounded value cannot be
used afterward. Ownership and typestate reinforce each other.
Finally, place delimiter configuration on only one state:
impl<Iter> Progress<Iter, Bounded> {
fn with_delimiters(mut self, delimiters: (char, char)) -> Self {
self.state.delimiters = delimiters;
self
}
}
Now method order is part of the API contract:
let configured = (0..10)
.progress() // Progress<_, Unbounded>
.with_bound() // Progress<_, Bounded>
.with_delimiters(('<', '>')); // available for Bounded
Calling with_delimiters first fails because no such method is implemented for
Progress<_, Unbounded>.
This is stronger than validating a boolean field at runtime. The type is a
witness that with_bound already succeeded.
Checkpoint 10: read the API from its types
Do not run the following invalid order yet. First predict the error:
(0..10)
.progress()
.with_delimiters(('<', '>'));
Your turn: Which concrete receiver type is Rust searching for the method on? Which
implblock contains the method? State the mismatch in words, then compile it and compare with the diagnostic.
Now consider the successful final type:
Progress<std::ops::Range<i32>, Bounded>
Without reading the implementation, we know:
- the wrapper owns a range iterator;
- its yielded item is
i32, inherited throughIterator::Item; - exact-length configuration has occurred;
- bounded-only methods are available;
- the compiler can reject calls inconsistent with that state.
The type is not merely storage layout. It records facts established by the program’s history.
The API-design lessons
1. Start with the caller
Write the desired call site before selecting traits or structs. The move from
progress(iter) to iter.progress() revealed that we wanted a composable
iterator adapter and therefore an extension trait.
2. Generalize one accidental restriction at a time
The progression was:
Vec<i32>
↓ generic item
Vec<T>
↓ generic capability
Iter: Iterator
↓ preserve caller's loop
Progress<Iter>: Iterator
↓ ergonomic method syntax
extension trait
↓ capability-specific configuration
ExactSizeIterator
↓ state-specific configuration
Progress<Iter, State>
Each feature answers a concrete limitation. None is present merely to showcase Rust.
3. Preserve relationships at the type boundary
The most important relationships are:
Progress<Iter>::Itemmust equalIter::Item;with_boundrequiresIter: ExactSizeIterator;with_delimitersrequires the bounded state;- consuming transitions prevent continued use of stale states.
These are the same kinds of questions that appear in larger systems. A job
runner might preserve Future::Output in JobHandle<T>. A database API might
make a committed transaction unavailable for further queries. An HTTP builder
might expose send only after a destination has been supplied.
4. Compile-time guarantees have a usability cost
Typestate can produce excellent autocomplete and prevent invalid calls, but it also creates longer types, more generic parameters, more implementation blocks, and sometimes intimidating diagnostics. API quality includes the failure experience, not just whether invalid code fails.
Ask:
- Is this misuse common or dangerous?
- Are there only a few meaningful states?
- Will callers understand the transition?
- Is a runtime
Resultclearer? - Does the compiler error point toward the fix?
Use the type system as a design budget, not as a contest.
Continue the workshop
Only now open the completed reconstruction. Compare it with your version one
impl block at a time, then work through these changes:
- Change the progress wrapper so rendering happens after the caller processes an item. What information would the iterator adapter need that it does not currently have?
- Add a
.with_message(String)method that works in both states. On whichimplblock should it live? - Add a
.with_delimiters(...)call before.with_bound()and read the full compiler error. Is the diagnostic good enough for a public library? - Replace typestate with
Option<Bounded>. Which invalid programs now compile? - Decide whether delimiters truly require a bounded state. Could a different rendering design remove the invalid state instead of encoding it?
The runnable reconstruction is in
examples/type-driven-progress/src/main.rs.
Interview summary
When discussing a Rust API, a strong explanation sounds like this:
I would begin with the intended call site and identify the invariants the implementation and caller must share. I would use ordinary generic bounds for capabilities, associated types when one implementing type determines another type, and typestate only when a small state machine prevents meaningful misuse. I would also evaluate compiler diagnostics and avoid making the public type surface more complicated than the guarantee warrants.
That answer is more valuable than simply naming traits, generics, or typestate. It explains why they belong in the design.