SuperAPI AI Directory

type-driven-design

Coding & development

View repository

Updated 10-28 16:14

About this skill

Type-Driven Design

A type is a promise about which states exist. This skill changes what the types allow — it is the only skill in the Rust bucket that restructures a domain model, and it does so with a stopping rule: encode an invariant in a type when violating it is a real bug class in this codebase, not when it is merely expressible. How a rejection is reported is /rust-errors; what a change to a published type costs is /rust-api-design.

The principle

A type that cannot represent the bad state removes the runtime check, the test for it, and the bug report about it. Ask of every struct: how many field combinations are constructible, and how many are valid? The gap between those two numbers is the surface where bugs live.

Enums over flag soup

Two booleans make four states; if only three are valid, the fourth is a latent bug:

// Four states, three valid: is_draft && is_published is nonsense.
struct Post {
    is_draft: bool,
    is_published: bool,
    published_at: Option<DateTime<Utc>>,
}

// Three states, three valid, and published_at cannot go missing.
enum Post {
    Draft { body: String },
    Scheduled { body: String, at: DateTime<Utc> },
    Published { body: String, at: DateTime<Utc> },
}

The same move covers the Option<T> pair smell: two Option fields where exactly one is always Some is an enum with two variants, and the impossible combination — both None — stops being constructible.

Parse, do not validate

A function that takes &str and checks it is a valid email address checks again at the next call site, and the next, because the type carries no proof. A function that takes Email — constructible only through Email::parse(&str) -> Result<Email, EmailError> — checks once at the boundary and never again. The type is the proof the check ran.

struct Email(String); // private field: the constructor is the only way in

impl Email {
    fn parse(input: &str) -> Result<Self, EmailError> {
        // the check, exactly once
    }
}

fn deliver(to: &Email) { /* no validation possible here — none needed */ }

The private field is the load-bearing part. A public field makes the constructor a suggestion, and the invariant dies the day someone builds the struct by literal.

Newtypes as invariant carriers

/idiomatic-rust reaches for a newtype to stop two arguments of the same primitive being swapped at a call site. Here the newtype carries an invariant: NonEmptyVec<T>, Percentage, Sanitized<String> — types whose constructor is the only entrance and whose fields are private. The standard library already runs this pattern: NonZeroU32 and friends, with the niche-optimization payoff that Option<NonZeroU32> is the same size as u32. The same argument applies to numbers, where the type system will otherwise let a byte count be added to a millisecond count — NonZero for what cannot be zero, TryFrom rather than as at every boundary, and NUMERICS.md for the rest.

Typestate for protocol order

When operations must happen in sequence — connect before send, build before finalize — the stage can be encoded in a type parameter, so calling out of order is a compile error rather than a runtime Err. The full worked example, the builder variant, and the costs live in TYPESTATE.md; the one-line version is that typestate is for small, fixed state machines where out-of-order use is a real bug class.

When to stop

Type-level modelling has a cost: worse error messages, heavier generics, and a refactor tax when the domain changes. A five-parameter typestate builder for a struct built once in main is over-engineering, and this skill says so rather than leaving the call to a reviewer.

Deferrals

How the constructor reports rejection is /rust-errors. Whether the resulting type is ergonomic for callers, and whether tightening a type is a semver break, is /rust-api-design. If the model change is being forced by borrow-checker pain, run /ownership-not-clone first — the answer may be ownership, not modelling.

Verification

cargo clippy --all-targets   # add -- -D warnings only if the repo has no lint config
cargo test

Run clippy at the lint level configured in the repo — never on top of one the repo already set. Then the specific evidence this skill claims: after the change, the runtime checks the type made impossible should be deleted, not left in place. Grep for the now-impossible branch and confirm it is gone, and confirm the tests that asserted the old runtime rejection were removed or rewritten to a compile-fail expectation. If the repo already uses trybuild, add a compile-fail case proving the illegal construction no longer compiles; if it does not, do not introduce the dependency as part of this change — state the compile-fail expectation in a doc comment instead.