Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Create a blank Rust binary called progress.rs.
  2. Type the current checkpoint into it. Do not paste the final example.
  3. Run it and confirm its behavior.
  4. Stop at every Your turn prompt and answer before scrolling farther.
  5. Make your own smallest change, even if you expect it to fail.
  6. 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 progress while 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 next using Option and a placeholder item type. Then compare it with the essential portion of Iterator:

#![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 for loop and add progress reporting without putting process(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 for loop 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> owns Iter;
  • next(&mut self) mutates the iteration position;
  • each returned Iter::Item is 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 impl adding progress directly to std::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:

  1. Rust finds an in-scope trait that provides progress.
  2. 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 Iterator be required to provide a length? Test that idea against 0... If not, where should the stronger requirement live: on Progress itself 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.
  • ExactSizeIterator is stronger than Iterator; 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:

  1. Make delimiters meaningful for unbounded output too.
  2. Return a runtime error for an invalid configuration.
  3. Make with_delimiters implicitly enable bounded display when possible.
  4. 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 runtime bool?

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, or self?

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 impl block 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 through Iterator::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>::Item must equal Iter::Item;
  • with_bound requires Iter: ExactSizeIterator;
  • with_delimiters requires 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 Result clearer?
  • 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:

  1. 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?
  2. Add a .with_message(String) method that works in both states. On which impl block should it live?
  3. Add a .with_delimiters(...) call before .with_bound() and read the full compiler error. Is the diagnostic good enough for a public library?
  4. Replace typestate with Option<Bounded>. Which invalid programs now compile?
  5. 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.