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

Book Background

Rust is frequently taught through the lens of memory safety — a pragmatic inheritance from its C and C++ lineage, where types are primarily understood as descriptions of machine memory layout and tools for catching implementation errors. This framing, while useful for systems programmers crossing from those languages, obscures a deeper and more powerful truth: Rust’s type system is one of the most sophisticated realisations of type theory available in a production programming language.

This book reframes Rust entirely from a type-theoretical perspective. Its central thesis is that types are propositions, implementations are proofs, and writing well-typed Rust is an act of formal reasoning about a problem domain — not an exercise in satisfying a compiler. This is not a metaphor. It is the precise content of the Curry-Howard correspondence, and Rust’s trait system, generic bounds, associated types, and GATs are all direct expressions of it.

The Structural Model

The book is structured around a stratified model of Rust’s type system derived from Henk Barendregt’s lambda cube and John Reynolds’ work on parametric polymorphism and relational parametricity. Types exist on a continuum from unconstrained parametric abstractions through progressively constrained families down to concrete runtime values.

The Two Categories

The model begins with a categorical distinction between two worlds:

𝒯 — The Type Category (compile-time). Objects are types, type constructors, trait bounds, generic families, and impl blocks. Morphisms are functions between types, subtyping relationships, and trait implementations as proof morphisms. The internal structure of 𝒯 is a lattice — trait bounds form a partial order where more constrained types sit lower (fewer inhabitants, more guaranteed capabilities).

𝒱 — The Value Category (runtime). Objects are runtime values and their memory representations. Morphisms are executable functions, ownership transfers, and borrows. This category has affine/linear structure — morphisms carry resource constraints enforced by the borrow checker.

The Boundary

Separating 𝒯 from 𝒱 is a forgetful functor F: 𝒯 → 𝒱. This functor:

  • Maps types to their runtime memory representations
  • Maps generic functions (type-level) to monomorphised machine code (value-level)
  • Maps trait implementations to vtables (in dyn cases) or inlined code (in monomorphic cases)
  • Erases lifetimes, phantom types, proof obligations, and type identity entirely

The functor is forgetful: many distinct objects in 𝒯 map to the same object in 𝒱. Lifetimes, for instance, form a complete sub-logic in 𝒯 — a proof system for pointer validity — and vanish entirely below the boundary with zero runtime cost.

The boundary has selective permeability — several mechanisms allow partial information transfer:

  • const generics — scalar values cross upward from 𝒱 into 𝒯 (the only dependent type mechanism in Rust)
  • dyn Trait — a vtable is a partial downward projection of type behaviour; identity is erased, interface survives
  • PhantomData<T> — a one-way valve; type information exists in 𝒯 only, zero runtime presence
  • TypeId — a narrow read-only channel; runtime can query type identity but not manipulate it

The Five Levels

The type category 𝒯 has internal structure — a hierarchy of abstraction levels:

LevelNameCore IdeaLambda CalculusRust Examples
0Value DomainConcrete types onlyλ→struct Point { x: f64, y: f64 }
1Unconstrained Parametric∀T with no boundsSystem F (λ2)fn identity<T>(x: T) -> T
2Constrained Parametric∀T: BoundSystem F + predicatesfn sorted<T: Ord>(data: Vec<T>)
3Proof Domainimpl blocks as witnessesCurry-Howard proofsimpl Display for Metres
4Type ConstructorType → Type, GATs, typestateHigher-kindedPhantomData<State> typestate

Beyond these, the book discusses levels that Rust does not reach — full dependent types, proof terms as runtime values, universe polymorphism, and effect systems — situating Rust precisely within the broader landscape.

Intended Audience

The intended audience is the working Rust programmer who suspects there is more to types than the compiler’s error messages reveal. The book assumes fluency with Rust’s syntax and standard patterns but does not require prior knowledge of type theory, category theory, or formal logic. All theoretical concepts are introduced from first principles and immediately grounded in concrete Rust.

Theoretical Heritage

The book draws on three major traditions:

  • Barendregt’s lambda cube — systematising typed lambda calculi along four axes, used to situate Rust precisely and explain what lies beyond it
  • Reynolds’ work on polymorphism — System F, parametricity, representation independence, and defunctionalisation, providing the formal foundations for the level structure
  • The Curry-Howard correspondence — the book’s philosophical centre, mapping propositions to types, proofs to implementations, and logical connectives to type constructors

How to Read This Book

The chapters are ordered by ascending level in the model. Each chapter introduces the type-theoretical ideas at that level, shows their concrete expression in Rust, and articulates what this perspective reveals that the standard framing misses. The final two chapters zoom out: Chapter 8 examines the boundary between 𝒯 and 𝒱 in full detail and uses it as a lens to unify the proof mechanisms introduced across the preceding chapters, and Chapter 9 places Rust in the broader landscape of type systems.

Readers comfortable with Rust’s generics and trait system can begin at Chapter 0 and read straight through. Those wanting theoretical foundations first may prefer to begin with Chapter 1 (Background and Foundations) and Chapter 2 (Propositions as Types) before proceeding.

Introduction

The Story You Have Been Told

If you learned Rust the way most people learn Rust, the story went something like this.

Rust is a systems programming language. It gives you control over memory layout and allocation, like C and C++, but it prevents you from making the mistakes that plague those languages — use-after-free, data races, dangling pointers, buffer overflows. It achieves this through a system of ownership, borrowing, and lifetimes, enforced at compile time by the borrow checker. Types tell the compiler how values are laid out in memory so it can verify that your program is safe.

This story is true. It is also, in a precise and important sense, backwards.

It frames types as servants of a runtime concern. The type system exists, in this telling, because memory is dangerous and programmers are fallible. Types are a safety net. Trait bounds are guardrails. Lifetime annotations are bookkeeping that the compiler insists on so it can check your work. You learn to satisfy the type checker the way you learn to satisfy a strict but well-meaning bureaucrat: comply with its demands and it will let your program run.

This framing has a cost. It trains you to think of type errors as obstacles rather than information. It teaches you to reach for clone() or Box<dyn Trait> when the types resist, rather than asking what the resistance is telling you. It obscures the fact that when you write a generic function with trait bounds, you are not merely describing what operations you need — you are making a formal statement about the structure of your problem, and the compiler is checking whether that statement is logically consistent.

This book tells a different story.

The Story This Book Tells

Types are propositions. Implementations are proofs. Writing well-typed Rust is an act of formal reasoning about a problem domain.

This is not a metaphor. It is the precise content of a result in mathematical logic called the Curry-Howard correspondence, discovered independently by Haskell Curry in 1934 and William Alvin Howard in 1969. The correspondence establishes a structural identity — not merely an analogy — between systems of formal logic and systems of typed computation. Propositions correspond to types. Proofs correspond to programs that inhabit those types. The rules of logical deduction correspond to the rules of type construction.

Rust’s type system is one of the most sophisticated realisations of this correspondence available in a production programming language. When you write:

#![allow(unused)]
fn main() {
fn sort<T: Ord>(data: &mut [T]) {
    data.sort();
}
}

you are not writing a function that “happens to require” the Ord trait. You are asserting a universally quantified proposition: for all types T, if T satisfies the ordering relation, then a slice of T values can be sorted. The bound T: Ord is the hypothesis. The function body is the proof. The impl Ord for MyType block that a caller must provide is the evidence that the hypothesis holds for their particular type.

When the compiler rejects your program, it is not catching a bug. It is identifying an invalid proof — a place where your reasoning about the problem does not follow from your premises.

This shift in perspective changes how you use the language. Types become a medium for thought, not a constraint to be worked around. The question changes from “how do I make the compiler accept this?” to “what am I actually claiming about this code, and is that claim true?”

Two Worlds

To make this precise, we need a structural model. This book develops one from first principles, but the core idea can be stated simply: Rust programs exist simultaneously in two worlds.

The first world is the type category, which we will write as 𝒯. This is the world of compile time — of types, traits, generic parameters, bounds, lifetime annotations, and impl blocks. Everything in 𝒯 is abstract. A generic function in 𝒯 is not a single function but a family of functions, one for each type that satisfies its bounds. A trait bound in 𝒯 is not a runtime check but a logical predicate. A lifetime annotation in 𝒯 is a proposition about how long a reference remains valid — a proposition that is verified by the borrow checker and then discarded entirely.

The second world is the value category, which we will write as 𝒱. This is the world of runtime — of concrete values, machine instructions, heap allocations, and pointer arithmetic. Everything in 𝒱 is concrete. A function in 𝒱 takes specific bytes and produces specific bytes. There are no type parameters, no trait bounds, no lifetimes. There is only computation.

Between these two worlds lies a boundary — a mapping from 𝒯 to 𝒱 that transforms abstract type-level structure into concrete runtime behaviour. This mapping is a forgetful functor: it preserves computational behaviour but erases logical structure. When your generic function is compiled, the boundary collapses each member of the family into a separate concrete function (monomorphisation). When your program runs, every lifetime annotation has vanished. Every trait bound has been verified and forgotten. Every PhantomData field occupies zero bytes.

This erasure is not merely an observation about Rust’s compilation strategy. It is the formal content of one of the language’s founding principles: zero-cost abstraction. The slogan — what you don’t use, you don’t pay for; what you do use, you couldn’t hand-code any better — is precisely the claim that the forgetful functor erases type-level structure without adding runtime overhead. Rust is a systems language, and systems languages are defined by their commitment to predictable, minimal-overhead execution. The two-category model reveals how Rust honours that commitment: it permits arbitrarily sophisticated structure in 𝒯 — generics, trait bounds, lifetimes, phantom types, typestate machines — and guarantees that the functor F carries none of that structure’s weight into 𝒱. The abstraction machinery exists to support formal reasoning about programs. The boundary ensures that this reasoning is free. Zero-cost abstraction is not a feature of Rust’s type system. It is a property of the boundary.

The boundary is also where Rust’s particular character emerges. Many languages have type systems. What makes Rust distinctive is where it draws the line between what exists above the boundary and what survives below it — and the fact that the boundary is not a wall but a selectively permeable membrane with specific, well-defined channels of communication.

The Levels

The type category 𝒯 is not flat. It has internal structure — a hierarchy of increasing abstraction that this book organises into five levels. Each level introduces a new kind of type-level expression, a new class of propositions that can be stated, and a new relationship to the boundary.

Level 0: The Value Domain. Concrete types with no parameters. struct Point { x: f64, y: f64 } lives here. So does enum Direction { North, South, East, West }. There are no type variables, no bounds, no generics. Types at Level 0 are fixed descriptions of data — they correspond to the simply typed lambda calculus, the most basic system of typed computation. A plain impl block at this level is a ground proposition: a statement that holds for one specific type, with no quantification.

Level 1: Unconstrained Parametric Polymorphism. Functions and types parameterised over a type variable T with no constraints whatsoever. fn identity<T>(x: T) -> T lives here. Because you know nothing about T, you can do almost nothing with it — you can move it, store it, return it, and that is all. This severe restriction is the source of Level 1’s power: the type signature alone determines the implementation, up to isomorphism. There is essentially one function with the signature T -> T, and it is the identity. This is the content of Reynolds’ parametricity theorem, and it operates at Level 1 because it depends on the absence of constraints.

Level 2: Constrained Parametric Polymorphism. Functions and types parameterised over T where T is bound by one or more traits. fn maximum<T: Ord>(a: T, b: T) -> T lives here. The bound T: Ord is a predicate — it restricts the domain of the universal quantifier to those types for which an ordering relation exists. Each bound you add narrows the domain and widens the set of operations available. Compound bounds like T: Ord + Display + Clone are intersections in a lattice of propositions.

Level 3: The Proof Domain. The world of impl blocks — the evidence that propositions hold. impl Ord for Metres is a proof object: it witnesses the proposition that Metres satisfies the ordering relation. Blanket implementations like impl<T: Ord> Ord for Vec<T> are something more: they are proof constructors, systematic derivations that produce new proofs from existing ones. The orphan rule — Rust’s requirement that you can only write an impl if you own either the trait or the type — is a coherence condition on this proof system, ensuring that no proposition has two conflicting proofs.

Level 4: Type Constructors and GATs. The highest level Rust reaches. Here, types are not values or parameters but functions on types. Vec is not a type — it is a function that takes a type T and produces the type Vec<T>. Generic associated types (GATs) extend this further, allowing associated types to be parameterised by lifetimes or other type variables. Typestate patterns use PhantomData to encode state machines entirely within 𝒯, with zero runtime representation — the boundary erases them completely, leaving behind only the guarantee that the state transitions were valid.

Beyond Level 4 lie territories that Rust does not enter: full dependent types (where arbitrary runtime values can appear in types), proof terms as first-class runtime values (where impl blocks can be passed as function arguments), and universe polymorphism (where you can quantify over the levels themselves). Chapter 11 maps this terrain and explains precisely where Rust’s boundary lies and why.

A Worked Example

To see all five levels operating simultaneously, consider a single piece of Rust code and examine it from each level’s perspective. We will use a function that finds the maximum element in a non-empty collection and formats it for display.

#![allow(unused)]
fn main() {
use std::fmt;

/// A non-empty wrapper that guarantees at least one element.
struct NonEmpty<T> {
    head: T,
    tail: Vec<T>,
}

impl<T> NonEmpty<T> {
    fn new(head: T, tail: Vec<T>) -> Self {
        NonEmpty { head, tail }
    }
}

fn format_max<T: Ord + fmt::Display>(collection: NonEmpty<T>) -> String {
    let max = collection.tail
        .into_iter()
        .fold(collection.head, |best, next| {
            if next > best { next } else { best }
        });
    format!("Maximum: {max}")
}
}

Now watch what each level reveals.

Level 0 sees the concrete ground. When a caller writes format_max(NonEmpty::new(3_i32, vec![1, 4, 1, 5])), Level 0 sees a function that takes a struct containing an i32 and a Vec<i32>, iterates through them comparing 32-bit integers, and produces a heap-allocated String. The types describe data layout. The function describes a computation.

Level 1 sees the parametric structure. NonEmpty<T> is not a single type but a family of types — one for each possible T. The impl<T> NonEmpty<T> block defines behaviour that is uniform across this entire family. The constructor new can move a value of any type T into the struct without knowing anything about what T is. At Level 1, NonEmpty is a container in the purest sense: it holds a value whose nature is irrelevant to the holding.

Level 2 sees the propositions. The signature fn format_max<T: Ord + fmt::Display> asserts: for all types T, if T is totally ordered and T is displayable, then a non-empty collection of T values can be reduced to a formatted string. The bound Ord is a proposition asserting that a total ordering exists. The bound Display is a proposition asserting that values can be rendered as text. The two bounds together — T: Ord + fmt::Display — form a conjunction, a compound proposition asserting both properties simultaneously. The function body constitutes a proof of this compound proposition: it uses > (which requires Ord) and format! (which requires Display), and the fact that the body compiles is verification that the proof is valid given the hypotheses.

Level 3 sees the proof witnesses. For this function to be callable with i32, there must exist impl Ord for i32 and impl Display for i32 — proof objects witnessing that i32 satisfies both propositions. These are provided by the standard library. If you define your own type struct Metres(f64) and wish to call format_max on a collection of Metres, you must supply the proofs yourself: impl Ord for Metres and impl Display for Metres. Each impl block is an obligation, a piece of evidence that you construct and that the compiler verifies. The orphan rule ensures that these proof obligations have unique solutions — there is exactly one impl Ord for i32, not two conflicting ones.

Level 4 sees the type-level functions. NonEmpty<_> is a type constructor — a function from types to types. Vec<_> is another. The composition NonEmpty<T> where T is constrained by Ord + Display carves out a fibre of the type constructor: not all possible NonEmpty<_> types, but only those whose element type satisfies the stated propositions. If this code used GATs or typestate encoding, Level 4 would also see the type-level state transitions — but even without them, the type constructor structure is present.

The boundary performs the final transformation. When the compiler processes format_max, it monomorphises: each concrete call site generates a specialised version of the function with all type parameters resolved. format_max::<i32> becomes a concrete function that compares i32 values and formats them. format_max::<Metres> becomes a different concrete function that compares Metres values and formats them. The trait bounds vanish — they have been verified and serve no further purpose. The generic family collapses into specific functions. What was a universally quantified proposition in 𝒯 becomes a collection of concrete procedures in 𝒱.

All of this happens to the same code, simultaneously. The five levels are not alternative interpretations; they are different views of a single structure, each revealing aspects invisible from the others.

What This Perspective Gives You

Reading Rust through the type-theoretical lens does not change what the language can do. It changes what you can see.

Types become a design language. When you model a problem with types, you are not just defining data structures — you are making formal claims about the relationships in your domain. A function signature becomes a theorem statement. An impl block becomes a proof that your type satisfies a contract. The question shifts from “what data does this hold?” to “what proposition does this express?”

Compiler errors become logical feedback. A type error is not a bug report; it is a notification that your reasoning contains a gap. The bound T: Ord that the compiler demands is not a hoop to jump through — it is an identification of a missing hypothesis in your argument. Once you see this, compiler errors become collaborators rather than obstacles.

Abstraction levels become visible. Most Rust programmers work at Levels 0 and 2 without distinguishing them. Recognising the level structure lets you choose your altitude deliberately. Some problems are best expressed as concrete data transformations (Level 0). Others are naturally universal statements about families of types (Levels 1–2). Still others require reasoning about proof structure itself (Level 3) or about type-level computation (Level 4). Knowing which level you are working at — and which level a problem wants to be expressed at — is a skill this book aims to develop.

The boundary becomes a tool. Understanding the forgetful functor from 𝒯 to 𝒱 lets you reason about zero-cost abstractions precisely. A PhantomData<State> typestate machine has rich structure in 𝒯 and zero cost in 𝒱 because the boundary erases it completely. A dyn Trait object has reduced structure because the boundary preserves behaviour but erases identity. Knowing what survives the boundary crossing and what does not is the key to writing Rust that is both expressive and efficient.

What This Book Is Not

This book is not an introduction to Rust. It assumes you can read and write Rust comfortably — that you understand ownership, borrowing, lifetimes, traits, generics, and enums at the level of practical use. If you are still learning the language, read The Rust Programming Language first and return here once Rust’s syntax and basic concepts feel natural.

This book is not a textbook on type theory. It draws on type theory extensively, but it introduces every concept it uses, defines every symbol before deploying it, and always connects the theory to concrete Rust. You will learn real type theory here — but you will learn it as a Rust programmer, not as a mathematics student.

This book is not about making Rust harder. The type-theoretical perspective does not add complexity to the language. It reveals structure that is already present. If anything, it makes certain aspects of Rust simpler, because it replaces ad hoc rules (“the orphan rule says you cannot…”) with principled explanations (“coherence requires that proofs be unique, therefore…”).

Map of the Book

The book follows the level structure upward through the type hierarchy, with three framing chapters.

Chapter 1: Background and Foundations introduces the theoretical tools the book uses: Barendregt’s lambda cube, Reynolds’ work on polymorphism and parametricity, the type lattice, and the two-category model. This chapter lays the groundwork; it is reference material as much as narrative.

Chapter 2: Propositions as Types develops the Curry-Howard correspondence in full. This is the philosophical heart of the book — the chapter that makes the central thesis precise and shows how every major feature of Rust’s type system maps onto a logical connective or proof rule.

Chapters 3 through 7 ascend the level hierarchy:

  • Chapter 3: The Value Domain (Level 0) — concrete types, plain impl blocks, ground propositions.
  • Chapter 4: Parametric Polymorphism (Level 1) — unconstrained generics, Reynolds parametricity, theorems for free.
  • Chapter 5: Constrained Generics (Level 2) — trait bounds as predicates, the constraint lattice, associated types.
  • Chapter 6: The Proof Domain (Level 3)impl blocks as witnesses, blanket impls as functors, coherence.
  • Chapter 7: Type Constructors and GATs (Level 4) — type-level functions, typestate, the limits of Rust’s abstraction.

Chapter 8: The Boundary examines the forgetful functor F: 𝒯 → 𝒱 in full depth — monomorphisation, dynamic dispatch, erasure, and the selective permeability of the membrane between compile time and runtime — and uses the boundary as a lens to unify the three proof mechanisms (inherent impls, trait impls, and proof-carrying types) introduced across the preceding chapters.

Chapters 9 and 10 are the applied synthesis — the practical payoff of the theoretical framework. They deploy all five levels against a single worked example (a procurement request system) and show how the type-theoretical vocabulary becomes a design instrument:

  • Chapter 9: Domain Modelling as Proof Construction — newtypes as proved invariants, trait ports as domain contracts, typestate lifecycles, error types as logical connectives, algebraic command and event flows.
  • Chapter 10: Architecture as Category — the ring topology as categorical structure, crate boundaries as proof scope, the dyn versus generic decision, composition roots, testing as alternative proof, and an anti-pattern catalogue diagnosed through the level model.

Chapter 11: Beyond Rust places Rust in the landscape of type systems. It maps the full lambda cube, examines what dependent types and effect systems make possible, and reflects on Rust’s design position as a principled choice within the space of possible type systems.

How to Read This Book

The chapters are designed to be read in order. Each one builds on vocabulary and concepts introduced in previous chapters, and the level model accumulates meaning as you ascend through it.

That said, three reading paths suit different starting points:

The linear path. Start at Chapter 1, read straight through. This is the intended experience. Each chapter adds one layer of the model, and the cumulative effect is a coherent reframing of Rust’s type system from the ground up.

The impatient path. Start at Chapter 2 (Propositions as Types) for the core thesis, then skip to whichever level chapter addresses the Rust features you use most. Return to Chapter 1 for foundations when the later chapters reference concepts you want to understand more deeply.

The comparative path. Read Chapters 1, 2, 8, and 11 — the framing chapters — for a high-level view of the model and Rust’s position in the type-system landscape. Read Chapters 9 and 10 for the applied synthesis. Dip into the level chapters as case studies.

Whichever path you choose, Chapter 2 is the one chapter you should not skip. Everything else in the book is an elaboration of the correspondence it establishes.

A Note on Notation

This book uses mathematical notation where it clarifies and avoids it where it obscures. You will encounter:

  • (for all) — universal quantification, corresponding to generic type parameters
  • (there exists) — existential quantification, corresponding to impl Trait in return position
  • — implication in logic, function types in Rust
  • — logical conjunction, corresponding to product types (A, B) and compound bounds A + B
  • — logical disjunction, corresponding to sum types enum { A, B }
  • — falsity / the uninhabited type, corresponding to ! in Rust
  • 𝒯 and 𝒱 — the type category and value category
  • F: 𝒯 → 𝒱 — the forgetful functor (the boundary)

A note on “value” versus “term”. Type theory conventionally speaks of terms — a term inhabits a type, and the lambda cube (introduced in Chapter 1) describes its axes as relationships between terms and types. Rust programmers speak of values — a value has a type, a function returns a value. This book follows Rust’s convention: we write 𝒱 for the value category and speak of values throughout.

The choice is not merely cosmetic. “Term” in type theory encompasses both the syntactic expression and the thing it evaluates to — it straddles the boundary between 𝒯 and 𝒱. “Value” in Rust refers specifically to the evaluated, runtime entity — an inhabitant of 𝒱, not of 𝒯. The two words meet precisely at the boundary: a value is what remains after a term has been evaluated and its type-level structure erased. When we discuss the lambda cube and other formal frameworks in Chapter 1, we will use “term” in its standard type-theoretical sense and note explicitly where it maps onto Rust’s “value”.

Every symbol is defined before it is used, and all formal statements are accompanied by their Rust translations. If you encounter notation that has not been explained, it is a bug in the book, not a gap in your preparation.

Beginning

The type-theoretical perspective on Rust is not esoteric knowledge reserved for language designers and academic researchers. It is, once you see it, the most natural way to understand what Rust’s type system is actually doing — and why it is designed the way it is.

The borrow checker is not a safety net. It is a proof checker for a logic of resource ownership.

Trait bounds are not constraints. They are hypotheses in a formal argument.

Generic functions are not templates. They are universally quantified propositions.

And when your code compiles, you have not merely passed a set of checks. You have constructed a proof.

Let us begin with the foundations.

Background and Foundations

The introduction promised a structural model of Rust’s type system — a model built from the lambda cube, Reynolds’ parametricity, and the Curry-Howard correspondence. This chapter lays the groundwork. It introduces the theoretical tools the rest of the book depends on, connects each one to concrete Rust, and establishes the vocabulary we will use throughout.

None of this requires prior knowledge of type theory, category theory, or formal logic. Every concept is introduced from first principles. But the concepts themselves are real mathematics, not simplified analogies, and they earn their keep: each one will illuminate something about Rust that the standard framing leaves opaque.

Terms and Types

Before we can talk about relationships between terms and types, we need to be precise about what these words mean.

A type is a classification. In the simplest setting, it tells you what kind of thing you are dealing with: i32 is a type, String is a type, bool is a type. But types can be far more than labels for data layout. A type can carry propositions (trait bounds), encode invariants (typestate), parameterise over unknown future types (generics), and assert relationships between types (associated types, where clauses). The richer the type system, the more a type can say.

A term is something that has a type — something that inhabits a type, in the formal vocabulary. The integer 42 is a term of type i32. The function fn double(x: i32) -> i32 { x * 2 } is a term of type fn(i32) -> i32. A closure, a struct instance, an enum variant — all terms.

In the type theory literature, “term” is the universal word for the things that types classify. In Rust, the natural word is “value”: you speak of a value of type i32, a value of type String, the return value of a function. As discussed in the Introduction, this book follows Rust’s convention in most contexts — but in this chapter, where we engage directly with formal frameworks, we will use “term” in its standard type-theoretical sense. The mapping is straightforward: what type theory calls a term, Rust calls a value (when evaluated) or an expression (when unevaluated). The distinction will matter when we reach the fourth axis of the lambda cube, where the question is precisely whether runtime values — terms — can appear inside types.

With these definitions in hand, we can state the central question that organises this chapter: what relationships between terms and types does a given type system permit?

Barendregt’s Lambda Cube

In 1991, Henk Barendregt introduced the lambda cube — a framework that classifies type systems along three independent axes of dependency. Each axis represents a relationship between terms and types that a system may or may not allow. By combining these axes, the cube identifies eight possible type systems, arranged as vertices of a three-dimensional cube.

The lambda cube begins from a common origin: the simply typed lambda calculus (λ→), a system where terms depend on terms and nothing else. Ordinary functions live here — a function takes a term and returns a term, and types are fixed, ground, and never parameterised. From this origin, three axes extend:

Axis 1: Terms Depending on Types (Polymorphism)

This axis introduces parametric polymorphism — the ability for a term (a function, a value) to be parameterised by a type. Moving along this axis takes us from λ→ to System F (λ2), Jean-Yves Girard and John Reynolds’ polymorphic lambda calculus.

In Rust, this axis is generics:

#![allow(unused)]
fn main() {
fn identity<T>(x: T) -> T {
    x
}
}

The function identity is a term that depends on a type T. It does not have a single fixed type like fn(i32) -> i32; instead, it has a family of types, one for each instantiation of T. In the formal notation of System F, its type would be written ∀T. T → T — a universally quantified type.

Rust inhabits this axis fully. Every generic function, every generic struct, every generic enum is a term depending on a type. This is so fundamental to Rust that it is easy to forget it represents a genuine step beyond what the simply typed lambda calculus can express. Languages like C (before _Generic) had no mechanism for terms to depend on types at all — every function had a fixed, concrete signature.

Axis 2: Types Depending on Types (Type Operators)

This axis introduces type constructors — the ability for a type to be parameterised by another type. Moving along this axis takes us from λ→ to λω̲ (lambda omega underbar), a system with type-level functions.

In Rust, this is what Vec, Option, Result, and every generic struct or enum definition represents:

#![allow(unused)]
fn main() {
struct Pair<A, B> {
    first: A,
    second: B,
}
}

Pair is not a type. It is a type operator — a function at the type level that takes two types as arguments and produces a type as its result. Pair<i32, String> is a type. Pair<bool, bool> is a different type. Pair itself is a function Type × Type → Type.

Rust’s trait system also operates on this axis. A trait with an associated type defines a type-level function:

#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}
}

For any type T implementing Iterator, the associated type T::Item is computed from T — it is a type that depends on another type.

Rust inhabits this axis fully. Every generic type definition is a type operator. Every associated type is a type-level function. Combined with Axis 1 (terms depending on types), Rust reaches System Fω — the corner of the lambda cube that has both polymorphism and type operators.

Axis 3: Types Depending on Terms (Dependent Types)

This axis introduces dependent types — the ability for a type to be parameterised by a runtime value. Moving along this axis takes us from λ→ to λΠ (lambda Pi), the simplest dependently typed system.

This is the axis where Rust mostly stops.

In a fully dependently typed language like Idris or Agda, you can write types that mention specific values:

-- Idris: a vector type indexed by its length
data Vect : Nat -> Type -> Type where
    Nil  : Vect 0 a
    (::) : a -> Vect n a -> Vect (n + 1) a

Here, the type Vect 3 Int is different from Vect 5 Int — the length, a runtime value, appears in the type. The type system tracks the length through every operation: concatenating a Vect 3 Int and a Vect 2 Int produces a Vect 5 Int, and this is checked at compile time.

Rust cannot do this in general. But it has one narrow channel: const generics.

#![allow(unused)]
fn main() {
struct Buffer<const N: usize> {
    data: [u8; N],
}

impl<const N: usize> Buffer<N> {
    fn zeroed() -> Self {
        Buffer { data: [0; N] }
    }
}
}

Here, N is a value — a usize — that appears in a type. Buffer<8> and Buffer<16> are different types. The constant N flows from the value world into the type world, influencing the type’s structure (the array length). This is dependent typing, restricted to a small set of types (usize, bool, char, and other scalar types) and a limited set of operations. Rust’s nightly compiler extends this further with generic_const_exprs, allowing type-level arithmetic like Buffer<{ A + B }> — but even stable const generics represent a genuine, if constrained, excursion along the dependent type axis.

Const generics are, in the language of the lambda cube, a pinhole into the dependent type axis — enough to express fixed-size arrays, const-parameterised algorithms, and certain compile-time computations, but far short of the full power of λΠ.

The Cube

Combining these three axes yields eight possible type systems. The lambda cube arranges them as vertices of a cube:

Barendregt’s Lambda Cube

Reading this diagram:

  • Axes

    • bottom-to-top Axis 1: adds Polymorphism
    • front-to-back Axis 2: adds Type Operators
    • left-to-right Axis 3: adds Dependent Types
  • Vertexes

    • λ→ (front-bottom-left): Simply typed lambda calculus. Terms depend on terms. Nothing else. This is Level 0 of our model.
    • λ2 (front-top-left): System F. Terms also depend on types (polymorphism). This is Level 1.
    • λω̲ (back-bottom-left): Type operators only. Types depend on types, but no polymorphism.
    • λω (back-top-left): System Fω. Polymorphism plus type operators. This is approximately where Rust lives — the combination of Levels 1, 2, and 4 in our model.
    • λP (front-bottom-right): Simple dependent types. Types depend on terms.
    • λP2 (front-top-right): Polymorphism plus dependent types.
    • λPω̲ (back-bottom-right): Type operators plus dependent types.
    • λC = λP2ω (back-top-right): The Calculus of Constructions — all three axes simultaneously. This is the system underlying Coq and Lean. Idris and Agda extend it further with universe polymorphism and other features.

Rust occupies λω — the back-top-left vertex — with const generics providing a narrow vertical channel into the dependent type dimension. The orphan rule, coherence, and monomorphisation are all consequences of how Rust inhabits this position: it commits to full erasure at the boundary, which is a choice that λω permits but does not require.

The Fourth Dependency

The three axes of the lambda cube are often described as three of the four possible dependency relationships between terms and types:

DependencyAxisCalculusRust
Terms depending on terms(baseline)λ→Ordinary functions
Terms depending on typesAxis 1λ2Generics: fn f<T>(...)
Types depending on typesAxis 2λω̲Type constructors: Vec<T>
Types depending on termsAxis 3λPConst generics (limited)

The “fourth dependency” — terms depending on terms — is so basic that it is not considered an axis at all. It is the baseline from which everything else extends. Every programming language with functions has it. It is simply the ability to write fn add(a: i32, b: i32) -> i32.

But notice the asymmetry: Rust has full support for the first two axes and almost none for the third. This is not an accident. Full dependent types — types depending on terms — require that the boundary between compile time and runtime become permeable in both directions. Values must be lifted into the type level, and type-level computation must be able to reason about arbitrary runtime values. Rust’s commitment to zero-cost abstraction and full erasure at the boundary makes this structurally incompatible with its design. The boundary is the reason Rust stops where it does in the lambda cube.

Reynolds’ Contributions

John C. Reynolds (1935–2013) made several contributions to the theory of programming languages that directly underpin this book’s model. Where Barendregt’s lambda cube gives us the axes, Reynolds gives us the content — the theorems that tell us what each position in the cube actually means for the programs we write.

Definitional Interpreters (1972)

In “Definitional Interpreters for Higher-Order Programming Languages,” Reynolds drew a distinction that foreshadows this book’s central structural device: the distinction between the object language (the language being defined) and the meta-language (the language used to define it).

When you write a Rust program, you are working in the object language. The compiler rustc is a meta-language program that interprets, transforms, and ultimately translates your Rust code into machine instructions. The boundary between 𝒯 and 𝒱 is, in Reynolds’ terms, the boundary between a type-level meta-language that reasons about programs and a runtime language that executes them.

Reynolds showed that the evaluation strategy of the meta-language (whether it evaluates arguments before or after passing them to functions) determines the semantics of the object language. This observation maps directly onto Rust’s monomorphisation strategy: the compiler’s choice to fully specialise generic functions at compile time is an evaluation strategy for the type-level meta-language. It fully evaluates type-level expressions before crossing the boundary. The alternative — preserving type abstraction at runtime via dictionary-passing, as Haskell does — is a different evaluation strategy for the same meta-language, yielding different performance characteristics and a different relationship to the boundary.

System F and Parametric Polymorphism (1974)

Reynolds independently discovered System F (Girard had discovered it earlier, in 1972, in the context of proof theory). In “Towards a Theory of Type Structure,” Reynolds gave the first semantic treatment of polymorphism: what it means for a function to have the type ∀T. T → T.

The answer is parametricity: a function of type ∀T. T → T must behave uniformly for all types. It cannot inspect T, cannot branch on what T is, cannot use any property of T whatsoever. The only thing it can do with a value of type T is return it unchanged. Therefore, the only function with this type is the identity function.

This is the foundation of Level 1 in our model. In Rust:

#![allow(unused)]
fn main() {
fn identity<T>(x: T) -> T {
    x
}
}

The signature <T>(T) -> T admits exactly one implementation (up to observational equivalence). You cannot write a function with this signature that does anything other than return its argument. The type alone determines the behaviour — not because of a language restriction, but because of a mathematical theorem.

Reynolds’ parametricity theorem generalises this. For any polymorphic type, parametricity constrains the possible implementations. Philip Wadler later popularised this as “theorems for free” — theorems that follow purely from a function’s type signature, without examining its implementation. We will develop this fully in Chapter 4.

Representation Independence (1978)

In “On the Relation Between Direct and Continuation Semantics” and subsequent work, Reynolds developed the concept of representation independence: if two implementations of an abstract type are related by a suitable simulation, then no program can distinguish them.

This is the formal justification for Rust’s coherence rules. When Rust requires that there be at most one impl of a given trait for a given type — the coherence condition enforced by the orphan rule — it is ensuring representation independence. If two different impl Ord for MyType blocks could coexist, a program’s behaviour might depend on which implementation was selected, violating the principle that the choice of representation should be unobservable.

Representation independence is the Level 3 guarantee. It says that proof witnesses (impl blocks) are interchangeable as long as they satisfy the same specification — and Rust’s coherence rules ensure this by making the question moot: there is only ever one witness, so there is nothing to interchange.

Relational Parametricity (1983)

In “Types, Abstraction, and Parametric Polymorphism,” Reynolds formalised parametricity using relations. The key insight: a polymorphic function does not merely operate uniformly on all types — it preserves all relations between types.

To understand what this means, consider a function f: ∀T. Vec<T> → Vec<T>. Parametricity says that for any two types A and B and any relation R between them, if you apply R element-wise to a Vec<A> to get a Vec<B>, then applying f before or after the relational mapping gives the same result. In other words, f commutes with all structure-preserving transformations of its type parameter.

What does this tell us? It tells us that f can reorder, duplicate, or drop elements — but it cannot manufacture new elements or inspect them. It can only shuffle the container structure. The type of the function — ∀T. Vec<T> → Vec<T> — constrains its behaviour to a specific class of operations, purely through the relational parametricity theorem.

In our model, relational parametricity formalises the boundary between Level 1 and Level 2. At Level 1 (unconstrained parametric polymorphism), a function must preserve all relations on its type parameter — it knows nothing about the parameter, so it cannot violate any relation. At Level 2 (constrained polymorphism, T: Bound), the function need only preserve relations consistent with the bound. The bound restricts the class of relations, which is equivalent to widening the class of permitted implementations. More constraints on T means more operations on T, which means more possible implementations, which means fewer “free theorems.”

Defunctionalisation (1972)

In the same landmark 1972 paper on definitional interpreters, Reynolds introduced defunctionalisation: a program transformation that eliminates higher-order functions by replacing closures with data structures and dispatch.

The transformation works as follows: every closure in a program is replaced by a variant of a sum type (an enum), carrying the closure’s captured environment as data. Every application site is replaced by a match on this sum type, dispatching to the appropriate code. The result is a first-order program — no closures, no function pointers, just data and pattern matching.

This is directly relevant to Rust in two ways.

First, Rust’s enum-and-match pattern is manual defunctionalisation:

#![allow(unused)]
fn main() {
enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rectangle(w, h) => w * h,
    }
}
}

This is a defunctionalised representation of what, in a higher-order style, might be a collection of closures each capturing their own dimensions and computing an area. The enum variants are the closure representations. The match is the dispatch.

Second, dyn Trait is automatic defunctionalisation performed by the compiler. A dyn Trait object is a pair of a data pointer (the captured environment) and a vtable (the dispatch table). The vtable is the reified sum type; dynamic dispatch is the match. This is closely analogous to Reynolds’ transformation, applied at the boundary: the compiler takes the higher-order type-level structure (a trait with methods) and lowers it into a first-order runtime representation (a vtable and a pointer). The analogy is not exact — Reynolds’ defunctionalisation replaces closures with a single sum type, while dyn Trait creates a vtable per trait — but the structural correspondence is clear.

In the language of our model, defunctionalisation is the forgetful functor F: 𝒯 → 𝒱 made explicit as a code transformation. It shows, concretely, what the boundary does to type-level abstractions when they must survive into runtime.

The Type Lattice

Rust’s trait bounds form a mathematical structure called a lattice — a partially ordered set where every pair of elements has a greatest lower bound (meet) and a least upper bound (join). Understanding this structure clarifies how bounds interact and what it means to add or remove constraints.

Partial Order on Bounds

Consider three trait bounds: Clone, Ord, and Clone + Ord. There is a natural ordering among them:

  • Clone + Ord is more constrained than either Clone or Ord alone. Fewer types satisfy Clone + Ord than satisfy Clone (because every type satisfying the compound bound must satisfy both). But functions bounded by Clone + Ord can do more — they have access to both cloning and ordering operations.

  • The unconstrained parameter T (with no bounds at all) is the least constrained position. Every type satisfies “no bounds.” But a function with no bounds on T can do almost nothing with T — only move, store, and return it.

This gives us a partial order on the space of bounds:

         (no bounds)          ← least constrained, fewest operations
          /       \
       Clone      Ord
          \       /
       Clone + Ord            ← more constrained, more operations
          |
    Clone + Ord + Hash
          |
         ...
          |
       concrete type          ← fully constrained, all operations

The ordering is: bound A ≤ bound B if every type satisfying B also satisfies A. In lattice terms, moving down adds constraints (intersects predicate regions), narrows the set of qualifying types, and expands the available operations. Moving up removes constraints, widens the set of qualifying types, and restricts the available operations.

This is a precise inversion of the usual intuition. More constraints mean fewer types but more power. Fewer constraints mean more types but less power. The programmer’s task, when designing a generic interface, is to find the right altitude in this lattice: constrained enough to do the job, unconstrained enough to admit the broadest useful set of types.

Concrete Types as Bottom Elements

A fully concrete type — i32, String, Vec<u8> — sits at the bottom of the lattice. It satisfies all the bounds it satisfies (trivially), and it has a specific set of operations determined by its impl blocks. There is no further constraining to do; the type is fully determined.

In the language of our model, Level 0 (the value domain) consists of these bottom elements. They are the fully resolved points — no parameters, no quantification, no freedom. Ascending from Level 0 into Levels 1 and 2 means replacing these fixed points with regions of the lattice: a generic parameter T: Ord denotes not a single point but the set of all types below Ord in the lattice.

The Lattice and the Levels

The lattice structure connects the levels as follows:

  • Level 0 works with specific points in the lattice (concrete types).
  • Level 1 works with the top of the lattice — the unconstrained region where T is entirely free.
  • Level 2 works with intermediate regions — bounded sublattices defined by trait predicates.
  • Level 3 provides the proof witnesses that a specific point (concrete type) actually lies within a given region (satisfies a given bound).

The lattice is, in effect, the geography of 𝒯. The levels tell you what kind of navigation you are performing.

Fibrations: Generic Types as Families

A generic type in Rust is not a single type. It is a family of types, one for each value of the type parameter. This family structure has a precise mathematical name: a fibration.

Consider Vec<T>. For each concrete type Ti32, String, bool, and so on — there is a corresponding type Vec<i32>, Vec<String>, Vec<bool>. These concrete types are the fibres of the type constructor Vec. The type constructor itself is the total space, and the type parameter T ranges over the base space (the set of all types, or some subset defined by bounds).

    Total space: Vec<_>
         |
         | fibre over i32:    Vec<i32>
         | fibre over String: Vec<String>
         | fibre over bool:   Vec<bool>
         | ...
         |
    Base space: { all types T }

A generic function operates on the total space. When you write:

#![allow(unused)]
fn main() {
fn first<T>(items: &[T]) -> Option<&T> {
    items.first()
}
}

you are defining a function that works across all fibres simultaneously. The single definition applies to &[i32], &[String], &[bool], and every other instantiation. Parametricity (Level 1) says that this function must behave the same way on every fibre — it cannot distinguish one fibre from another.

When you add a bound — fn sort<T: Ord>(items: &mut [T]) — you restrict the base space to those types satisfying Ord. The function now operates only on a sub-fibration: the fibres over orderable types. Within this restricted family, the function can exploit ordering — but it still cannot distinguish between specific fibres. It knows T is orderable but not which orderable type T is.

When the compiler monomorphises, it performs the inverse operation: it takes the total-space definition and generates code for each specific fibre that the program actually uses. The family collapses into a set of individual functions, one per fibre. This is the forgetful functor applied to the fibration structure: the family relationship is erased, leaving only its individual members.

The fibration viewpoint clarifies a common point of confusion. When a Rust programmer asks “what type is Vec?”, the precise answer is: Vec is not a type; it is a type-level function. Vec<i32> is a type (a fibre). Vec<T> where T is bound by some trait is a sub-family (a sub-fibration). The generic parameter is the index into the family, and the bounds on the parameter determine which sub-family you are working with.

Decomposing a Generic Function

A Rust function signature like fn print_sorted<T: Ord + Display>(items: &mut [T]) reads as a single declaration, but it actually bundles three distinct structures that belong to different levels of our model:

  1. The parameter domain (Level 2): the set of types { T | T: Ord + Display } — a region of the type lattice. This determines which types the function can be instantiated with.

  2. The function family (Levels 1–2): for each T in the parameter domain, a concrete function &mut [T] → (). The domain and codomain may themselves depend on T — the family is indexed by the type parameter, which is the fibration structure described above.

  3. The proof witnesses (Level 3): for each T in the parameter domain, there must exist an impl Ord for T and an impl Display for T. These are the evidence that a specific type actually belongs to the parameter domain. Without them, the function cannot be called at that type.

These three components are distinct mathematical objects, even though Rust’s syntax collapses them into a single line. The parameter domain is a predicate on types. The function family is a fibred collection of concrete functions. The proof witnesses are the evidence that connects the two — they certify that a given type lies within the predicate region, enabling the corresponding fibre of the function family to be used.

The distinction matters because each component has a different fate at the boundary. The function family survives — monomorphisation generates a concrete function for each fibre. The parameter domain is consumed — the compiler uses it to determine which fibres to generate, then discards it. The proof witnesses are verified and erased — their computational content (the method implementations) is inlined into the generated code, but the proof objects themselves vanish.

The later chapters examine each component in its own right: Chapter 4 treats the function family (Level 1), Chapter 5 treats the parameter domain (Level 2), and Chapter 6 treats the proof witnesses (Level 3).

The Two-Category Model

The introduction sketched the two categories 𝒯 and 𝒱 informally. Here we develop them with more precision, while remaining accessible to readers without category theory background.

A category, for our purposes, is a collection of objects and morphisms (arrows between objects) that can be composed. You already think in categories without knowing it: types and functions form a category (objects are types, morphisms are functions, composition is function composition). The category concept simply gives this structure a name and lets us reason about it abstractly.

𝒯 — The Type Category

The type category 𝒯 contains everything that exists at compile time:

Objects in 𝒯:

  • Concrete types: i32, String, Point
  • Generic type families: Vec<T>, Option<T>, HashMap<K, V>
  • Trait bounds: Ord, Clone + Display, Iterator<Item = u8>
  • Lifetime parameters: 'a, 'static
  • Impl blocks: impl Display for Point, impl<T: Ord> Ord for Vec<T>

Morphisms in 𝒯:

  • Generic function signatures (maps between type families)
  • Subtyping relationships (lifetime subtyping: 'static: 'a)
  • Trait implementations as proof morphisms (connecting types to their trait obligations)
  • Blanket impl derivations (if T: Ord then Vec<T>: Ord)

The internal structure of 𝒯 is the type lattice described above: bounds form a partial order, and the proof structure (Level 3) connects types to their positions in this lattice.

𝒱 — The Value Category

The value category 𝒱 contains everything that exists at runtime:

Objects in 𝒱:

  • Concrete values: 42_i32, "hello".to_string(), Point { x: 1.0, y: 2.0 }
  • Monomorphised function code: the specific machine instructions for sort::<i32>
  • Vtables: the dispatch tables for dyn Display
  • Memory allocations: stack frames, heap blocks

Morphisms in 𝒱:

  • Executable functions (concrete code that transforms values)
  • Ownership transfers (moves)
  • Borrows (shared and exclusive references)
  • Memory operations (allocation, deallocation)

𝒱 has affine structure: morphisms carry resource constraints. A move is a morphism that consumes its source — the source value is no longer available after the morphism is applied. This is the borrow checker operating within 𝒱. From the categorical perspective, 𝒱 is not an ordinary category but an affine one, where morphisms may be used at most once.

F: 𝒯 → 𝒱 — The Forgetful Functor

The forgetful functor F maps objects and morphisms of 𝒯 to objects and morphisms of 𝒱, preserving computational structure while erasing logical structure:

𝒯 (compile time)F𝒱 (runtime)
Generic function familySet of monomorphised functions
Trait bound T: Ord(erased — verified and discarded)
Lifetime 'a(erased — no runtime presence)
PhantomData<T>Zero-sized, no representation
impl Trait for TypeInlined method code (static dispatch)
dyn TraitVtable pointer + data pointer
const N: usizeLiteral value in generated code

The functor is forgetful because it loses information: many distinct objects in 𝒯 map to the same object in 𝒱. For example:

#![allow(unused)]
fn main() {
struct Metres(f64);
struct Seconds(f64);
}

In 𝒯, Metres and Seconds are distinct types — they may have different trait implementations, different associated semantics, different positions in the type lattice. In 𝒱, after F is applied, both are identical: a single f64 in memory. The type distinction — which prevents you from adding a distance to a time — exists only in 𝒯 and is erased by F.

This is the formal content of Rust’s “zero-cost abstraction” principle: the newtype pattern costs nothing at runtime because the forgetful functor maps the newtype to its inner representation. The abstraction — the logical distinction between metres and seconds — lives entirely in 𝒯.

The Phase Distinction

The boundary between 𝒯 and 𝒱 is not a concept invented for this book. It is a well-studied phenomenon in programming language theory, examined from several angles by different research traditions. Understanding these traditions clarifies what the boundary is and why it takes the form it does.

Cardelli’s Phase Distinction (1988)

Luca Cardelli, in work on the Quest language and modular programming, articulated the phase distinction: the principle that a programming language’s semantics can be cleanly separated into a compile-time phase (type checking, module linking) and a runtime phase (computation, side effects), and that these phases should not interfere with each other.

The phase distinction is a design principle, not a mathematical necessity. Some languages violate it deliberately: Lisp-family languages blur the distinction through macros and eval; dependently typed languages dissolve it by allowing runtime values in types. Rust embraces the phase distinction aggressively — more aggressively than most languages — because its zero-cost abstraction guarantee depends on a clean separation. If type information leaked into runtime, it would have a cost.

In our model, Cardelli’s phase distinction is the assertion that F: 𝒯 → 𝒱 is a functor, not merely a function — it preserves the categorical structure, mapping compile-time composition to runtime composition in a systematic way. The phases are distinct but formally connected.

Harper, Mitchell, and Moggi’s Phase Separation (1990)

Robert Harper, John Mitchell, and Eugenio Moggi formalised the phase distinction in their work on XML (a module calculus, unrelated to the markup language). They proved that for a certain class of type systems, it is possible to separate a program into a static (compile-time) part and a dynamic (runtime) part such that:

  1. The static part can be computed without executing the program.
  2. The dynamic part does not depend on the static part at runtime.
  3. The separation preserves the program’s meaning.

This is precisely the property that monomorphisation exploits. When Rust compiles a generic function, the “static part” (type parameters, trait bounds, lifetime annotations) is fully resolved at compile time. The “dynamic part” (the function body with types resolved) can execute without any reference to the static part. The separation is total: no runtime cost, no residual type information.

Harper et al.’s result tells us that this separation is sound — it does not change the program’s meaning. The monomorphised version of sort::<i32> behaves identically to what the generic sort::<T> would produce if types were retained at runtime. The forgetful functor is meaning-preserving.

Davies and Pfenning’s Modal Analysis (2001)

Rowan Davies and Frank Pfenning offered a different perspective, framing the phase distinction through modal logic. In their analysis, compile time and runtime are possible worlds in the modal logic sense, connected by an accessibility relation:

  • The current world is compile time — the world where type-level reasoning takes place.
  • The future world is runtime — the world where computation takes place.
  • A necessity operator (□) corresponds to values available at compile time (they are necessarily available at runtime too — constants, type information baked into code).
  • A possibility operator (◇) corresponds to values that exist only at runtime (user input, I/O results, dynamically computed values).

In this framing, the functor F: 𝒯 → 𝒱 is the accessibility relation between worlds. The phase distinction says that you can access compile-time information at runtime (it has been baked in), but you cannot access runtime information at compile time (you have not yet entered that world). Dependent types partially invert this by allowing certain future-world values to be “pulled back” into the current world — which is precisely what const generics do in a limited way.

The modal framing is particularly illuminating for understanding lifetimes. A lifetime 'a is a proposition about the future world (runtime): “this reference will be valid for this region of execution.” The borrow checker verifies this proposition in the current world (compile time) using the rules of its sub-logic. The proposition is then erased by F — it has done its work. The future world never sees the proof, only the guarantee that the proof was valid.

Three Framings, One Boundary

These three traditions — Cardelli’s phase distinction, Harper et al.’s phase separation, Davies and Pfenning’s modal worlds — are different descriptions of the same structural feature. This book uses the categorical framing (the forgetful functor) as its primary language because it composes naturally with the level structure and the lattice. But the reader should be aware that the same ideas have been formalised multiple times, from different starting points, and all three descriptions converge on the same insight: the boundary between compile time and runtime is not incidental to Rust’s design. It is the central architectural decision from which everything else follows.

Connecting the Foundations

Let us draw the threads together. The theoretical tools introduced in this chapter form a coherent structure:

Barendregt’s lambda cube tells us where Rust sits in the space of possible type systems. Rust occupies the λω vertex (polymorphism plus type operators), with a pinhole into the dependent-type dimension via const generics. This position determines what Rust can express at the type level and what it cannot.

Reynolds’ work tells us what that position means:

  • Parametricity (Levels 1–2): the type signature alone constrains the implementation. The more polymorphic a function, the fewer things it can do.
  • Representation independence (Level 3): proof witnesses are interchangeable. Coherence ensures uniqueness.
  • Defunctionalisation (the boundary): type-level abstraction can be systematically lowered to runtime representation. dyn Trait is the canonical example.

The type lattice gives us the geography of the type category 𝒯. Trait bounds form a partial order. Generic parameters range over regions of this lattice. Concrete types are points. The programmer’s task is to find the right altitude.

Fibrations give us the structure of generic types and functions. A generic type is a family indexed by its parameter. A generic function is a family of functions over that family. Monomorphisation collapses the family to its individual fibres.

The two-category model (𝒯 and 𝒱 connected by F) gives us the architecture of the whole system. Everything above the boundary is logical structure; everything below is computational behaviour. The boundary preserves behaviour and erases logic. Zero-cost abstraction is a property of this boundary.

The phase distinction gives us the theoretical justification for the boundary. The separation of compile time and runtime is sound, meaning-preserving, and deliberate. It is not a limitation of Rust’s implementation but a design commitment.

The remaining chapters will use these tools to examine each level of the type hierarchy in detail. Chapter 2 introduces the Curry-Howard correspondence — the theorem that connects the entire structure to formal logic. Then Chapters 3 through 7 ascend the levels, each chapter building on the vocabulary established here.

The foundations are laid. Now we can build.

Propositions as Types

This chapter is the heart of the book. Everything before it was preparation; everything after it is elaboration. The claim that types are propositions and implementations are proofs is not a pedagogical device or a loose analogy. It is a theorem — the Curry-Howard correspondence — and this chapter develops it in full, shows how it maps onto Rust, identifies where Rust truncates it, and demonstrates what it means to read a Rust program as a logical argument.

The Correspondence

In 1934, Haskell Curry observed a structural similarity between the axioms of combinatory logic and the types of certain primitive functions. In 1969, William Alvin Howard extended this observation into a full isomorphism between natural deduction (a system of formal proof) and the simply typed lambda calculus (a system of typed computation). The result, now known as the Curry-Howard correspondence, establishes that:

  • Every proposition in logic corresponds to a type in a type system.
  • Every proof of a proposition corresponds to a value (an inhabitant) of the corresponding type.
  • Every rule of logical deduction corresponds to a rule of type construction.

The correspondence is not a metaphor. It is a precise structural isomorphism: the same mathematical object can be read as a logical statement or as a type specification, and the two readings are formally equivalent. A proof that A implies B is the same thing as a function from A to B. A proof that A and B both hold is the same thing as a pair containing a value of type A and a value of type B.

This chapter develops the correspondence connective by connective, showing how each logical operation maps onto a Rust type construction. We begin with the full table, then examine each row in detail.

The Full Correspondence

LogicType TheoryRust
PropositionTypeType or trait bound
ProofInhabitant of a typeValue, impl block
True (⊤)Unit type()
False (⊥)Uninhabited type! (never type)
Conjunction (A ∧ B)Product type (A × B)(A, B), structs
Disjunction (A ∨ B)Sum type (A + B)enum { A(A), B(B) }
Implication (A → B)Function type (A → B)fn(A) -> B
Negation (¬A)A → ⊥fn(A) -> !
Universal (∀x. P(x))Dependent product (Π)fn f<T: Bound>(...)
Existential (∃x. P(x))Dependent sum (Σ)-> impl Trait

Each row of this table is a theorem. Let us examine them.

Truth: The Unit Type

The simplest proposition is truth — the proposition that is always provable, that carries no information, that asserts nothing beyond its own validity.

In logic, this is written ⊤ (top, or verum). In Rust, it is the unit type ():

#![allow(unused)]
fn main() {
fn trivial() -> () {
    ()
}
}

The unit type has exactly one inhabitant: (). A proof of truth requires no evidence — you simply produce the trivial witness. Every function that “returns nothing” in Rust is actually returning a proof of truth. When a block ends with a semicolon, its type becomes () — under Curry-Howard, the block’s result is a trivial proof, constructed implicitly by discarding the preceding expression’s value.

This may seem pedantic, but it matters. The unit type is the identity element for conjunction (product types), just as truth is the identity element for logical AND. A struct with one field of type () is isomorphic to a struct without that field — the trivial proposition contributes nothing.

Falsity: The Never Type

The opposite of truth is falsity — the proposition that has no proof. It is the claim that cannot be substantiated.

In logic, this is written ⊥ (bottom, or falsum). In Rust, it is the never type !:

#![allow(unused)]
fn main() {
fn diverge() -> ! {
    loop {}
}
}

The never type has no inhabitants. There is no value of type !. A function that claims to return ! can never actually return — it must diverge (loop forever), panic, or exit the process. The type ! is a promise that “this code path is unreachable.”

Under Curry-Howard, the uninhabited type is the false proposition. Since there is no proof of falsity, there is no value of the uninhabited type. And just as anything follows from a false premise in logic (ex falso quodlibet), any type can be produced from an uninhabited type in Rust. We can see this clearly by defining our own uninhabited type:

#![allow(unused)]
fn main() {
enum Void {}

fn absurd(x: Void) -> String {
    // This function is vacuously valid — it can never be called,
    // because no one can construct a value of type Void to pass in.
    match x {}
}
}

The enum Void has no variants, so it has no inhabitants — it is a user-defined ⊥. The empty match match x {} on a value of Void is logically sound: since x has no possible values, there are no cases to handle, and the expression can be assigned any type. This is ex falso made executable.

Uninhabited types appear naturally in Rust wherever impossibility needs to be expressed. The standard library’s Infallible type (used as the error type of infallible conversions) plays the same role as our Void: Result<T, Infallible> is a result that can never be an error, which is logically T ∨ ⊥, which simplifies to T. The ! (never) type serves as Rust’s built-in ⊥ in return position — a function returning ! can never return, and the compiler uses this to reason about control flow and dead code.

Conjunction: Product Types

Logical conjunction — A ∧ B, “A and B” — corresponds to the product type: a type whose values contain both a value of type A and a value of type B.

In Rust, the canonical product type is the tuple:

#![allow(unused)]
fn main() {
fn prove_conjunction() -> (u32, bool) {
    (42, true)
}
}

To produce a value of type (u32, bool), you must supply both a u32 and a bool. This is exactly what a proof of A ∧ B requires: evidence for A and evidence for B. The tuple is the proof; its components are the sub-proofs.

Structs are named product types:

#![allow(unused)]
fn main() {
struct Measurement {
    value: f64,
    unit: String,
    timestamp: u64,
}
}

A Measurement is a conjunction of three propositions: there exists a value (f64), a unit description (String), and a timestamp (u64). To construct a Measurement, you must supply all three. The struct fields are the conjuncts.

Elimination (using a conjunction) mirrors logical AND-elimination. From a proof of A ∧ B, you may extract a proof of A or a proof of B. In Rust, this is field access:

#![allow(unused)]
fn main() {
struct Measurement { value: f64, unit: String, timestamp: u64 }
fn extract_value(m: &Measurement) -> f64 {
    m.value  // AND-elimination: from (A ∧ B ∧ C), extract A
}
}

The correspondence extends to nested products. A struct with five fields is a five-fold conjunction. A tuple (A, B, C) is A ∧ B ∧ C. The unit type () is the empty conjunction — truth.

Disjunction: Sum Types

Logical disjunction — A ∨ B, “A or B” — corresponds to the sum type: a type whose values contain either a value of type A or a value of type B, but not both.

In Rust, the canonical sum type is the enum:

#![allow(unused)]
fn main() {
enum StringOrInt {
    Text(String),
    Number(i64),
}
}

A value of type StringOrInt is either Text(s) for some String s, or Number(n) for some i64 n. It is a proof of “either String or i64,” but you do not know which until you examine it.

Introduction (constructing a disjunction) mirrors OR-introduction. From a proof of A, you may conclude A ∨ B:

#![allow(unused)]
fn main() {
enum StringOrInt { Text(String), Number(i64) }
fn from_number(n: i64) -> StringOrInt {
    StringOrInt::Number(n)  // OR-introduction: from B, conclude A ∨ B
}
}

Elimination (using a disjunction) mirrors OR-elimination: if you know A ∨ B, and you can show that A implies C and B implies C, then C holds. In Rust, this is pattern matching:

#![allow(unused)]
fn main() {
enum StringOrInt { Text(String), Number(i64) }
fn describe(value: StringOrInt) -> String {
    match value {
        StringOrInt::Text(s) => format!("text: {s}"),      // A → C
        StringOrInt::Number(n) => format!("number: {n}"),   // B → C
    }
}
}

The match expression requires you to handle every variant — every disjunct. If you add a new variant to the enum and forget to handle it, the compiler rejects the match. Under Curry-Howard, this is sound: an incomplete case analysis is an invalid proof.

Rust’s Option<T> is the disjunction T ∨ ⊤ (either a value of type T, or nothing — where None carries the unit type). Result<T, E> is the disjunction T ∨ E (either success or error). These are among the most used types in Rust, and they are, logically, disjunctions.

Implication: Function Types

Logical implication — A → B, “if A then B” — corresponds to the function type: the type of values that, given a value of type A, produce a value of type B.

#![allow(unused)]
fn main() {
fn length_is_even(s: &str) -> bool {
    s.len() % 2 == 0
}
}

This function is a proof of the proposition “if there is a string, then there is a boolean.” More precisely, it is a proof of &str → bool. Given evidence of the hypothesis (a string reference), it produces evidence of the conclusion (a boolean).

Implication is the most fundamental connective in the correspondence. It is the bridge between propositions: if you know A, and you know A → B, then you know B. In type theory, this is function application: if you have a value of type A and a function of type A → B, applying the function gives you a value of type B.

Chained implication is function composition:

#![allow(unused)]
fn main() {
fn is_short_even_string(s: &str) -> bool {
    s.len() < 10 && s.len() % 2 == 0
}
}

Multi-argument functions are iterated implications. A function fn f(a: A, b: B) -> C corresponds to A → B → C, which is equivalent (under currying) to A → (B → C): given A, produce a function that, given B, produces C.

Negation

Logical negation — ¬A, “not A” — is defined in classical logic as A → ⊥: if A were true, we could derive falsehood. Equivalently: A is false if assuming A leads to a contradiction.

In Rust, ¬A corresponds to a function from A to the never type:

#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn not_possible(x: std::convert::Infallible) -> ! {
    match x {}
}
}

This function compiles because it can never be called — no value of type Infallible exists to pass as the argument. The empty match match x {} is valid for the same reason as the Void example below: there are no cases to handle. Under Curry-Howard, the function is a proof of ¬Infallible (i.e. Infallible → ⊥): it demonstrates that assuming Infallible is inhabited leads to an uninhabited type, which is the constructive definition of negation.

In practice, negation in Rust is more commonly expressed through the absence of an impl block. The proposition “i32 implements Iterator” is false in Rust — not because there exists a proof of its negation, but because no proof of the proposition exists. This is a difference between Rust’s type system and full constructive logic. Rust can express “this type does not implement this trait” only through absence, not through a first-class negation type. We will return to this limitation when we discuss proof erasure at the boundary.

Universal Quantification: Generics

Logical universal quantification — ∀x. P(x), “for all x, P(x) holds” — corresponds to the dependent product type (Π-type): a type whose values are functions that work uniformly for all possible inputs from some domain.

In Rust, this is a generic function:

#![allow(unused)]
fn main() {
fn wrap_in_vec<T>(value: T) -> Vec<T> {
    vec![value]
}
}

Read this signature under Curry-Howard: for all types T, given a value of type T, produce a value of type Vec<T>. The generic parameter <T> is the universal quantifier. The function body is the proof that the proposition holds for all T. The compiler verifies this proof by checking that the body makes no assumptions about T that are not warranted by its bounds (which here are empty — no assumptions at all).

When bounds are added, the quantification becomes conditional:

#![allow(unused)]
fn main() {
fn to_string_vec<T: std::fmt::Display>(items: &[T]) -> Vec<String> {
    items.iter().map(|item| item.to_string()).collect()
}
}

This reads: for all types T, if T is displayable, then given a slice of T values, produce a vector of strings. The bound T: Display is the hypothesis of a conditional universal statement. In logical notation: ∀T. (Display(T) → (&[T]Vec<String>)). The universal quantifier and the implication are both present, nested.

The Rust programmer who writes <T: Display> is not “adding a constraint.” They are stating the hypothesis of a universal theorem. The function body is the proof. The impl Display for ConcreteType that a caller must provide is the discharge of the hypothesis for a specific T.

Multiple Quantifiers

Multiple generic parameters are iterated universal quantification:

#![allow(unused)]
fn main() {
fn zip_pair<A, B>(a: A, b: B) -> (A, B) {
    (a, b)
}
}

This reads: ∀A. ∀B. (A → B → A ∧ B). For all types A and B, given evidence of A and evidence of B, produce evidence of their conjunction. This is a proof of AND-introduction, universally quantified over the types.

Existential Quantification: impl Trait in Return Position

Logical existential quantification — ∃x. P(x), “there exists an x such that P(x)” — corresponds to the dependent sum type (Σ-type): a type that packages together a witness and a proof that the witness satisfies a predicate.

In Rust, this is impl Trait in return position:

#![allow(unused)]
fn main() {
fn make_counter() -> impl Iterator<Item = u32> {
    0u32..
}
}

Read this under Curry-Howard: there exists a type T such that T implements Iterator<Item = u32>, and this function returns a value of that type. The caller knows the returned value satisfies Iterator<Item = u32>, but does not know which specific type it is. The concrete type (here, std::ops::Range<u32>) is hidden — existentially quantified away.

The key property of existential quantification is that the witness is opaque. The caller can use the returned value through the trait interface (next(), map(), filter(), etc.) but cannot name its type, match on its structure, or use any capability not guaranteed by the bound. This is the logical dual of universal quantification: where ∀ says “this works for any type you choose,” ∃ says “I have a specific type, but I am not telling you which one.”

The asymmetry between argument position and return position in Rust reflects this duality precisely:

  • fn f<T: Trait>(x: T)universal: the caller chooses T. The function must work for all T satisfying Trait.
  • fn f() -> impl Traitexistential: the callee chooses T. The caller must work with any T satisfying Trait.

This is the ∀/∃ duality made concrete in function signatures.

dyn Trait as Existential Quantification

There is a second form of existential quantification in Rust: dyn Trait. A Box<dyn Iterator<Item = u32>> also says “there exists some type implementing Iterator<Item = u32>” — but unlike impl Trait, it is a runtime existential. The concrete type is erased not just from the caller’s view but from the compiled code: the value is accessed through a vtable, and the original type identity is lost.

Under Curry-Howard, dyn Trait is an existential that survives the boundary. The forgetful functor F does not fully erase it — it transforms it into a vtable, which is a partial projection of the proof. The trait methods survive as function pointers; the type identity does not. This is a runtime existential, and it has a cost: dynamic dispatch, heap allocation (when boxed), and loss of monomorphisation opportunities.

By contrast, impl Trait in return position is a compile-time existential. The concrete type is known to the compiler and monomorphised away — the existential is erased at the boundary, leaving no runtime trace. Both are existential quantification, but they live on different sides of the boundary.

Trait Bounds as Propositions

With the individual connectives established, we can now read trait bounds as what they are: propositions in a formal logic.

A single bound states a single proposition:

#![allow(unused)]
fn main() {
fn minimum<T: Ord>(a: T, b: T) -> T {
    if a <= b { a } else { b }
}
}

The bound T: Ord asserts: “T possesses a total ordering.” This is a proposition about T, and the existence of impl Ord for T is its proof.

Compound bounds are conjunctions:

#![allow(unused)]
fn main() {
fn display_sorted<T: Ord + std::fmt::Display>(items: &mut Vec<T>) {
    items.sort();
    for item in items.iter() {
        println!("{item}");
    }
}
}

The bound T: Ord + Display asserts: “T possesses a total ordering and T can be formatted for display.” The + operator in bounds is logical conjunction (∧). To call this function for some type MyType, you must provide two proofs: impl Ord for MyType and impl Display for MyType.

Where clauses allow more complex propositions:

#![allow(unused)]
fn main() {
fn convert_and_collect<I, T, U>(iter: I) -> Vec<U>
where
    I: Iterator<Item = T>,
    T: Into<U>,
{
    iter.map(|item| item.into()).collect()
}
}

The where clause is a set of hypotheses: “if I is an iterator over T values, and T is convertible into U, then the iterator can be converted into a vector of U values.” Each line of the where clause is a separate hypothesis in the argument.

Supertraits encode implication between propositions:

#![allow(unused)]
fn main() {
use std::fmt;
trait Summary: fmt::Display {
    fn summarise(&self) -> String;
}
}

The supertrait bound Summary: Display asserts: “if a type satisfies Summary, then it satisfies Display.” In logical notation: ∀T. (Summary(T) → Display(T)). This is a universally quantified implication — a theorem about the relationship between two propositions.

Impl Blocks as Proof Terms

If trait bounds are propositions, then impl blocks are their proofs. An impl block is a proof term — a piece of evidence that a particular type satisfies a particular proposition.

#![allow(unused)]
fn main() {
struct Celsius(f64);

impl std::fmt::Display for Celsius {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.1}°C", self.0)
    }
}
}

This impl block is a proof of the proposition “Celsius can be formatted for display.” The proof consists of a concrete implementation of the fmt method — evidence that the required operation exists and produces the required result.

The compiler verifies this proof by checking that every method required by the trait is provided, with the correct signature and return type. If a method is missing, the proof is incomplete. If a return type is wrong, the proof is invalid. The compiler’s error messages are, under this reading, reports of proof failures.

Blanket impls are universally quantified proofs — proof schemas that apply to all types satisfying certain conditions:

#![allow(unused)]
fn main() {
trait Printable {
    fn print(&self);
}
impl<T: std::fmt::Display> Printable for T {
    fn print(&self) {
        println!("{self}");
    }
}
}

This reads: for all types T, if T is displayable, then T is printable. It is a universal theorem in the proof domain. The impl block provides the proof construction — given the hypothesis (T: Display), it derives the conclusion (T: Printable) by using println!, which requires Display.

The orphan rule ensures that proofs are unique: for any given type and trait, at most one impl block exists in the entire program. Under Curry-Howard, this is a coherence condition on the proof system. It ensures that the logical system is consistent — that there is no type for which two conflicting proofs of the same proposition could cause ambiguity. We will examine this in depth in Chapter 6.

Reading a Program as a Logical Argument

Now let us apply the full correspondence to a realistic Rust program and read it as a logical argument from start to finish.

Consider a small system for validating and processing configuration data:

#![allow(unused)]
fn main() {
use std::fmt;
use std::str::FromStr;

/// A validated configuration value that has been parsed
/// from a string and confirmed to be within acceptable bounds.
struct Validated<T> {
    value: T,
    source: String,
}

trait Bounded {
    fn is_in_bounds(&self) -> bool;
}

fn parse_and_validate<T>(input: &str) -> Result<Validated<T>, String>
where
    T: FromStr + Bounded,
    T::Err: fmt::Display,
{
    let value: T = input
        .parse()
        .map_err(|e: T::Err| format!("parse error: {e}"))?;

    if !value.is_in_bounds() {
        return Err(format!("value out of bounds: {input}"));
    }

    Ok(Validated {
        value,
        source: input.to_string(),
    })
}
}

Read this under Curry-Howard:

The type Validated<T> is a conjunction: a value of type T ∧ a source string. It is a product type asserting that both a parsed value and its origin exist together.

The trait Bounded is a proposition: “this type has a notion of acceptable bounds.” Any type that implements Bounded has proven it can answer the question “is this value acceptable?”

The function signature is a universally quantified conditional statement:

∀T. (FromStr(T) ∧ Bounded(T) ∧ Display(T::Err)) → (&str → (Validated(T) ∨ String))

In prose: For all types T, if T can be parsed from a string, T has bounds checking, and T’s parse error is displayable, then given a string reference, we can produce either a validated value or an error message.

The where clause lists the hypotheses:

  • T: FromStr — hypothesis 1: T can be parsed from strings
  • T: Bounded — hypothesis 2: T has bounds checking
  • T::Err: Display — hypothesis 3: parse errors can be rendered as text

The return type Result<Validated<T>, String> is a disjunction: either a validated value (success) or an error message (failure). This is honest: the function acknowledges that parsing may fail, and encodes both outcomes in the type.

The function body is the proof. It proceeds step by step:

  1. Parse: using hypothesis 1 (FromStr), attempt to parse the input. This may fail — the ? operator handles the Err case, mapping the parse error to a string using hypothesis 3 (Display).

  2. Validate: using hypothesis 2 (Bounded), check whether the parsed value is acceptable. If not, return Err.

  3. Construct: if both steps succeed, construct the Validated conjunction — pairing the value with its source.

Each step uses exactly the hypotheses it needs. The proof is valid because every operation is justified by a stated hypothesis. If you remove any bound from the where clause, the corresponding step in the body becomes unjustified, and the compiler rejects the proof.

This is what it means to read a Rust program as a logical argument: the signature states the theorem, the bounds state the hypotheses, and the body constructs the proof.

Lifetimes as a Sub-Logic

Lifetimes are the most dramatic example of the Curry-Howard correspondence in Rust — and the most dramatic example of proof erasure at the boundary.

A lifetime annotation is a proposition about temporal validity: the claim that a reference will remain valid for a certain region of execution. The borrow checker is a proof checker for these propositions. And every lifetime proof is completely erased at the boundary — no runtime trace, no cost, no representation.

#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}
}

Read the lifetime annotations under Curry-Howard:

The signature asserts: given two string references that are both valid for region ’a, the returned reference is also valid for region ’a. This is a proposition about the temporal relationships between three references. The function body is the proof: since the return value is always one of the two inputs, and both inputs are valid for 'a, the output is necessarily valid for 'a.

The borrow checker verifies this proof by tracking the lifetimes of all references through the function body. If the proof is invalid — if the function could return a reference that outlives its data — the borrow checker rejects the program.

Lifetime subtyping establishes an ordering on lifetime propositions. If 'a: 'b (read: 'a outlives 'b), then a reference valid for 'a is also valid for 'b. This is a form of logical implication: the proposition “valid for the longer region” implies the proposition “valid for the shorter region.”

The 'static lifetime is the strongest lifetime proposition: “this reference is valid for the entire duration of the program.” It is the top element in the lifetime sub-lattice. Every other lifetime is implied by 'static: if something is valid forever, it is valid for any region.

The entire lifetime system — the annotations, the variance rules, the subtyping relationships, the borrow checker’s analysis — constitutes a complete sub-logic within 𝒯. It is a logic of resource ownership and temporal validity, with its own propositions (lifetime bounds), its own proof rules (borrowing rules), and its own proof checker (the borrow checker).

And all of it is erased at the boundary. When your program runs, there are no lifetimes. There are no borrow checks. The proofs have been verified and discarded. The references are bare pointers, indistinguishable from what you would write in C — except that the proofs, verified above the boundary, guarantee they are safe.

This is zero-cost abstraction in its purest form: an entire proof system, operating in 𝒯, verified at compile time, erased by the forgetful functor, contributing nothing to the runtime cost. The safety guarantees are real; the mechanism that establishes them is invisible in 𝒱.

Proof Erasure: Where Curry-Howard Stops

In full type-theoretic systems like Coq, Agda, or Idris, proof terms are values. You can pass a proof as an argument, store it in a data structure, pattern-match on it, and compute with it at runtime. The Curry-Howard correspondence extends all the way down: proofs are first-class citizens of the computation.

Rust does not do this. In Rust, proof terms (impl blocks) are not values. You cannot write:

// Not valid Rust — illustrative pseudocode
fn apply_proof<T>(proof: impl Ord for T, a: T, b: T) -> T {
    proof.cmp(a, b)
}

You cannot store an impl block in a variable, pass it as a function argument, or return it from a function. The impl block exists in 𝒯 — it is verified at compile time — and then it is erased at the boundary. In 𝒱, there is no proof. There is only the code that the proof licensed.

This is where Rust truncates the Curry-Howard correspondence. The correspondence maps:

Full Curry-HowardRust
Propositions → TypesTrait bounds → Types
Proofs → ValuesImpl blocks → erased
Proof checking → Type checkingBorrow checking → Type checking
Proof terms at runtimeNot available

The truncation is not a limitation — it is a design choice, and it is the same design choice that gives Rust zero-cost abstraction. If proof terms survived as runtime values, they would have a runtime cost. Haskell makes the opposite choice: its trait-like mechanism (type classes) is implemented via dictionary passing, where the impl block (the “instance dictionary”) is passed as an implicit runtime argument. This makes proofs first-class but adds runtime overhead: every polymorphic function call carries an extra pointer to the dictionary.

The comparison clarifies what the boundary costs and what it buys:

FeatureRustHaskellCoq/Idris
Proof termsErasedDictionaries (runtime)First-class values
Polymorphic dispatchMonomorphised (static)Dictionary-passed (dynamic)Depends on extraction
Runtime cost of proofsZeroPointer per constraintFull value
Can inspect proofs at runtimeNoPartially (via dictionaries)Yes
Can compute with proofsNoLimitedYes

Rust’s position is: proofs exist to verify correctness; once verified, they are discarded. This is the forgetful functor applied to the proof domain: F maps every proof in 𝒯 to its computational residue in 𝒱, which may be zero (for lifetime proofs), inlined code (for monomorphised trait methods), or a vtable (for dynamic dispatch). The proof itself — the reason the code is correct — never exists at runtime.

What Proof Erasure Precludes

Because proofs are erased, certain things are impossible in Rust that would be straightforward in a dependently typed language:

Proof-dependent branching. You cannot branch at runtime on which impl is being used:

// Not valid Rust — illustrative pseudocode
fn process<T: Serialize>(value: T) {
    if T::serialization_is_json() {
        // fast path for JSON
    } else {
        // general path
    }
}

The impl block’s internal structure is not available at runtime. The compiler may inline different code for different types (monomorphisation), but the programmer cannot write conditional logic that inspects the proof.

Proof transport. You cannot take a proof from one context and use it in another:

// Not valid Rust — illustrative pseudocode
fn store_proof<T: Ord>() -> ProofOf<T, Ord> {
    // capture the proof that T: Ord and return it
}

Since proofs are not values, they cannot be stored, transported, or deferred. The proof must be available at the call site where it is needed — it cannot be fetched from elsewhere at runtime.

These limitations are real, and they define the boundary of what Rust can express compared to dependently typed systems. But within that boundary, the correspondence holds fully: types are propositions, bounds are hypotheses, functions are proofs, and the compiler is the proof checker.

The Logical Structure of Common Patterns

To solidify the correspondence, let us read several common Rust patterns as logical statements.

Option<T> as T ∨ ⊤

Option<T> is Some(T) or None. Under Curry-Howard, None carries the unit type (no data), so Option<T> is the disjunction T ∨ ⊤ — “either T holds, or trivially true (no information).” This reads naturally: an optional value either exists or does not.

Result<T, E> as T ∨ E

Result<T, E> is Ok(T) or Err(E). This is the disjunction T ∨ E — “either success (with evidence T) or failure (with evidence E).” The ? operator is an elimination form for this disjunction: if the result is Err, propagate the error (take the E branch); if Ok, continue with the T value.

The From/Into traits as Implication

impl From<A> for B is a proof that A → B: given a value of type A, you can produce a value of type B. The Into trait is the same implication in reverse notation. When you write let b: B = a.into(), you are applying the proof of A → B to a value of A.

Iterators as Repeated Disjunction

An iterator of type Iterator<Item = T> produces a sequence of Option<T> values — repeated applications of the disjunction T ∨ ⊤, where Some(t) is the left branch (“here is a value”) and None is the right branch (“no more values”). The iterator protocol is a sequence of logical queries.

PhantomData<T> as a Vacuous Proposition

PhantomData<T> is a type that mentions T but carries no value of type T at runtime. Under Curry-Howard, it is a proposition about T that requires no evidence — a vacuous truth that exists purely to create a type-level relationship. It exists in 𝒯 and is erased to zero bytes by F. We will examine this fully in Chapter 7.

Thinking in Propositions

The Curry-Howard correspondence is not merely a theoretical observation. It is a practical tool for thinking about program design.

When you design a function signature, you are writing a theorem statement. Ask yourself:

  • What am I claiming? The return type is the conclusion. The argument types are the hypotheses. Is the claim true? Is it as strong as it could be?

  • Are my hypotheses minimal? Each trait bound is a hypothesis. If you can prove the conclusion with fewer hypotheses, you should — a more general theorem is a more reusable function.

  • Is my conclusion honest? A function returning Option<T> admits that it might fail. A function returning T claims it always succeeds. An unwrap() is a claim that the None case is impossible — an assertion that requires its own (informal) proof.

  • Does the structure of my proof match the structure of the problem? If the problem is naturally a disjunction (multiple cases), the proof should use an enum and pattern matching. If it is naturally a conjunction (multiple simultaneous requirements), the proof should use a struct or tuple.

When you encounter a compiler error, read it as logical feedback:

  • “The trait bound T: Ord is not satisfied” means: “your argument lacks a necessary hypothesis proof term.”
  • “Expected Result<T, E>, found T” means: “your conclusion claims certainty, but your proof goes through a fallible step.”
  • “Cannot move out of borrowed content” means: “your proof of ownership is insufficient — you have a proof of borrowing, which is weaker.”

The compiler is not obstructing you. It is telling you where your reasoning is incomplete.

The Correspondence at Each Level

The Curry-Howard correspondence manifests differently at each level of our model:

Level 0 — Ground propositions. Concrete types are specific propositions about data layout. A plain impl block is a proof that holds for one type, with no quantification. The logic is simple: fixed premises, fixed conclusions.

Level 1 — Universal quantification without hypotheses. fn f<T>(x: T) -> T is ∀T. T → T. The proposition holds for all types, unconditionally. The proof must work without inspecting T. Reynolds’ parametricity governs what proofs are possible.

Level 2 — Universal quantification with hypotheses. fn f<T: Bound>(x: T) -> T is ∀T. Bound(T) → T → T. The proposition holds for all types satisfying the bound. The hypotheses enable operations; the proof uses them.

Level 3 — The proof terms themselves. Impl blocks are the objects of this level. Blanket impls are universally quantified proofs. The orphan rule is the coherence condition. At this level, you reason not about types but about the structure of proofs — which proofs exist, how they are derived, and why they are unique.

Level 4 — Propositions about type constructors. GATs assert propositions parameterised by lifetimes or types. Typestate machines encode temporal propositions (“the connection must be opened before data is sent”) in the type structure. The proofs at this level exist entirely in 𝒯 — the boundary erases them completely.

The boundary — Where proof meets computation. The forgetful functor F preserves the computational content of proofs (the code that runs) while erasing their logical content (the reason the code is correct). This is the truncation point of Curry-Howard in Rust: below the boundary, there are values and functions. Above the boundary, there are propositions and proofs. The correspondence lives entirely in 𝒯.

What Lies Beyond

In Coq, when you write a proof that a list is sorted, the proof is a data structure. You can examine it, transform it, optimise it. In Agda, when you write a proof that two paths in a type are equal, that proof can influence the computation. In Idris, you can choose on a case-by-case basis which proofs to erase and which to retain at runtime.

These languages occupy the full Curry-Howard correspondence. They live at the top of the lambda cube, where types depend on terms, terms depend on types, and the boundary between propositions and computation dissolves. The cost is complexity: dependent type checking is undecidable in general, and these languages require programmer-supplied hints to guide the proof checker. The benefit is expressiveness: anything you can state, you can prove; and anything you can prove, you can compute with.

Rust makes a different choice. It occupies a lower vertex of the lambda cube, maintains a strict boundary between 𝒯 and 𝒱, and erases proofs completely. The cost is expressiveness: there are propositions that Rust cannot state and proofs that Rust cannot construct. The benefit is the guarantee that the propositions it can state carry zero runtime cost.

The remaining chapters of this book explore what Rust can express within these constraints — level by level, from the ground types of Level 0 to the type constructors of Level 4. Each chapter builds on the correspondence established here: types are propositions, implementations are proofs, and the compiler is the proof checker that stands between your reasoning and the machine.

The Value Domain (Level 0)

Every Rust programmer begins here. Before generics, before trait bounds, before lifetimes and type parameters and associated types — there are concrete types. A struct with named fields. An enum with known variants. A function that takes an i32 and returns an i32. No polymorphism, no abstraction, no quantification. Just fixed types describing fixed data, and fixed functions transforming one piece of data into another.

This is Level 0: the value domain. It corresponds to the simply typed lambda calculus (λ→) — the most basic system of typed computation, sitting at the origin of the lambda cube. And while it may seem too simple to warrant a full chapter, it is precisely this simplicity that makes Level 0 the right place to establish the core ideas that the higher levels will generalise.

At Level 0, every concept we need is present in its simplest form. Types are propositions — but ground propositions, with no quantifiers. Impl blocks are proofs — but ground proofs, tethered to specific types. The boundary between 𝒯 and 𝒱 exists — but the mapping is nearly trivial, because there is little type-level structure to erase. By understanding what the model says at Level 0, we build intuition for what it will say at higher levels, where the structure becomes richer and the erasure more dramatic.

Concrete Types as Points

In the type lattice introduced in Chapter 1, concrete types are the bottom elements — fully determined points with no free parameters. i32 is a point. String is a point. bool is a point. Each one occupies a fixed position in the lattice, satisfying exactly the trait bounds it satisfies, and no others.

A concrete type is, in a precise sense, a ground proposition: a statement that is either true or false, with no variables to bind. The type i32 asserts “there exist 32-bit signed integers.” The existence of values like 0, 1, -42 proves this proposition — they are its witnesses. An uninhabited type like enum Void {} is a ground proposition with no proof: it asserts the existence of values that do not exist.

At Level 0, the programmer works entirely with these fixed points. There is no parameterisation, no abstraction over types. Every function signature names its types explicitly, and every type is known at the point of definition.

Products: Structs as Conjunctions

The struct is Level 0’s primary tool for building composite types. A struct with named fields is a product type — a conjunction of its field types.

#![allow(unused)]
fn main() {
struct Sensor {
    id: u64,
    latitude: f64,
    longitude: f64,
    active: bool,
}
}

Under Curry-Howard, Sensor is the proposition u64 ∧ f64 ∧ f64 ∧ bool: there exists a sensor identifier, a latitude, a longitude, and an activity flag, all simultaneously. To construct a Sensor, you must supply evidence for all four conjuncts:

#![allow(unused)]
fn main() {
struct Sensor { id: u64, latitude: f64, longitude: f64, active: bool }
fn new_sensor(id: u64, lat: f64, lon: f64) -> Sensor {
    Sensor {
        id,
        latitude: lat,
        longitude: lon,
        active: true,
    }
}
}

This constructor is a proof of the implication u64 → f64 → f64 → Sensor: given an identifier, a latitude, and a longitude, a sensor value can be produced (with active fixed to true as an internal decision of the proof).

The Dual Life of a Struct

A struct definition has a dual existence in the two-category model. Consider what Sensor means in each category:

In 𝒱 (the value category), Sensor is a concrete memory layout: 8 bytes for the u64, 8 bytes for each f64, and 1 byte for the bool, arranged contiguously with alignment padding. A value of type Sensor is this specific arrangement of bytes.

In 𝒯 (the type category), Sensor is a named proposition — a node in the type lattice connected to other nodes by trait implementations. It may satisfy Debug, Clone, Send, Sync, and other trait bounds, each established by an impl block. These connections exist purely at compile time and are erased at the boundary.

The dual existence is the key insight of Level 0. A concrete type is simultaneously a runtime data description and a compile-time logical entity. The struct keyword creates both at once: a point in 𝒱 (the memory layout) and a point in 𝒯 (the type identity, with its lattice position). Most Rust programmers think primarily about the 𝒱 side — “what does this look like in memory?” — but the 𝒯 side is where the type-theoretical structure lives.

Tuple Structs and Newtypes

Tuple structs are product types with positional rather than named fields:

#![allow(unused)]
fn main() {
struct Point(f64, f64);
struct Metres(f64);
struct Seconds(f64);
}

The newtype pattern — a struct with a single field — is the simplest illustration of the two-category perspective. As Chapter 1 described, the forgetful functor F maps both Metres and Seconds to the same representation:

F(Metres)  = f64 (8 bytes, IEEE 754)
F(Seconds) = f64 (8 bytes, IEEE 754)

The type distinction lives entirely in 𝒯 and is erased at the boundary — the newtype costs nothing at runtime. Yet in 𝒯, Metres and Seconds can have completely different impl blocks, different trait implementations, different positions in the type lattice. This is the Level 0 insight: the logical structure is rich; the runtime structure is trivial.

Sums: Enums as Closed-World Disjunctions

If structs are conjunctions, enums are disjunctions. An enum type asserts: “a value is one of these variants, and nothing else.”

#![allow(unused)]
fn main() {
enum Command {
    Start,
    Stop,
    SetSpeed(f64),
    MoveTo { x: f64, y: f64 },
}
}

Command is the disjunction ⊤ ∨ ⊤ ∨ f64 ∨ (f64 ∧ f64): a command is either a start signal (no data), a stop signal (no data), a speed setting (carrying an f64), or a move instruction (carrying two f64 coordinates). The variants without data carry the unit type implicitly — they are trivially true disjuncts.

Closed-World Reasoning

Enums enable closed-world reasoning: the set of variants is fixed at definition time, known to the compiler, and cannot be extended without modifying the source. This is logically significant. When you pattern-match on an enum, you can enumerate every possibility:

#![allow(unused)]
fn main() {
enum Command { Start, Stop, SetSpeed(f64), MoveTo { x: f64, y: f64 } }
fn describe(cmd: &Command) -> String {
    match cmd {
        Command::Start => "starting".to_string(),
        Command::Stop => "stopping".to_string(),
        Command::SetSpeed(v) => format!("setting speed to {v}"),
        Command::MoveTo { x, y } => format!("moving to ({x}, {y})"),
    }
}
}

The match is exhaustive: every possible variant is handled. The compiler verifies this. If you add a fifth variant to Command and forget to update the match, the compiler rejects the program — the proof of disjunction elimination is incomplete.

This exhaustiveness guarantee is a logical property that exists in 𝒯. In 𝒱, the match compiles to a branch table or a chain of comparisons — efficient runtime dispatch. The guarantee that all cases are covered is verified above the boundary and erased below it. The runtime never checks whether a match is exhaustive; the compile-time proof makes that check unnecessary.

Enums as State Machines

At Level 0, enums naturally model finite state machines with known states:

#![allow(unused)]
fn main() {
enum ConnectionState {
    Disconnected,
    Connecting { address: String },
    Connected { address: String, latency_ms: u32 },
    Error { message: String },
}

impl ConnectionState {
    fn is_connected(&self) -> bool {
        matches!(self, ConnectionState::Connected { .. })
    }

    fn address(&self) -> Option<&str> {
        match self {
            ConnectionState::Connecting { address }
            | ConnectionState::Connected { address, .. } => Some(address),
            _ => None,
        }
    }
}
}

Each variant represents a state, and the associated data represents the information available in that state. The match expression forces the programmer to handle each state explicitly. This is a closed-world disjunction used as a state model — and at Level 0, it is the primary tool for expressing “this thing can be in one of several configurations.”

We will see in Chapter 7 how Level 4 encodes state machines differently, using phantom types to make invalid state transitions unrepresentable rather than merely unhandled. The Level 0 encoding is less restrictive: the type system does not prevent you from constructing a ConnectionState::Connected directly without going through Connecting first. That stronger guarantee requires type-level machinery that Level 0 does not possess.

Enums versus Traits: Closed versus Open

The distinction between closed-world (enum) and open-world (trait) disjunction is one of the most important design decisions in Rust. At Level 0, only the closed-world option is available:

PropertyEnum (Level 0)dyn Trait (Level 2+)
Variants/implementorsFixed at definitionOpen to extension
Adding new variantsRequires modifying sourceAdd impl block anywhere
Pattern matchingExhaustive, compiler-checkedNot available
Method dispatchStatic (match)Dynamic (vtable)
Data layoutKnown at compile timeErased behind pointer

At Level 0, the programmer trades extensibility for totality. An enum cannot be extended by downstream code — but every function that handles it can be verified to handle all cases. A trait can be implemented by any type — but you cannot enumerate all implementations and handle each one.

This trade-off is not a deficiency of either approach. It is a fundamental duality in logic: closed-world reasoning (everything is known) versus open-world reasoning (new facts may appear). Enums are closed; traits are open. Level 0 works exclusively with the closed world.

Ground Propositions: Plain Impl Blocks

At Level 0, an impl block contains no generic parameters and no trait bounds. It is a ground proposition — a statement about a specific type, with no quantification:

#![allow(unused)]
fn main() {
struct Circle {
    radius: f64,
}

impl Circle {
    fn new(radius: f64) -> Self {
        Circle { radius }
    }

    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn circumference(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }

    fn scale(&self, factor: f64) -> Circle {
        Circle::new(self.radius * factor)
    }
}
}

This impl block makes specific claims about Circle: that a circle can be constructed from a radius, that its area and circumference can be computed, and that it can be scaled by a factor. These are ground propositions — they hold for Circle and no other type. There is no <T>, no where clause, no generality.

Under Curry-Howard, each method is a proof of an implication:

  • new: f64 → Circle (given a radius, a circle exists)
  • area: &Circle → f64 (given a circle, an area exists)
  • circumference: &Circle → f64 (given a circle, a circumference exists)
  • scale: &Circle → f64 → Circle (given a circle and a factor, a new circle exists)

These proofs are entirely concrete. They name specific types, use specific operations, and produce specific results. There is nothing to monomorphise, nothing to erase at the boundary beyond the type identity itself. The forgetful functor maps each method to a concrete function in 𝒱 — a specific sequence of machine instructions operating on specific memory layouts.

Trait Implementations as Ground Proofs

When a concrete type implements a trait without generics, the result is a ground proof of a named proposition:

#![allow(unused)]
fn main() {
use std::fmt;

struct Metres(f64);

impl fmt::Display for Metres {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}m", self.0)
    }
}
}

This is a proof of the proposition Display(Metres): the type Metres can be formatted for display. The proof is ground — it applies to Metres and nothing else. It establishes Metres’s position in the type lattice: Metres now sits below the Display bound, in the region of types that satisfy that predicate.

At Level 0, each such proof is written individually. If you have ten types that need Display, you write ten impl blocks — ten separate ground proofs. This is the characteristic cost of Level 0: every proposition must be proven for every type, one at a time. The universal quantification of Level 1 and the blanket impls of Level 3 exist precisely to eliminate this repetition — but at Level 0, we have no such tools.

Functions at Level 0

A function at Level 0 has a fixed signature: every type is concrete, every domain and codomain is known.

#![allow(unused)]
fn main() {
fn clamp(value: f64, min: f64, max: f64) -> f64 {
    if value < min {
        min
    } else if value > max {
        max
    } else {
        value
    }
}
}

This function operates on f64 and nothing else. It cannot clamp an i32, a u8, or any other numeric type. Its domain is fixed. Under Curry-Howard, it is a proof of the implication f64 → f64 → f64 → f64 — a relationship between specific propositions, with no variables.

The limitation is immediately apparent. If you also need to clamp integers, you must write a separate function:

#![allow(unused)]
fn main() {
fn clamp_i32(value: i32, min: i32, max: i32) -> i32 {
    if value < min {
        min
    } else if value > max {
        max
    } else {
        value
    }
}
}

The body is identical. The logic is identical. Only the types differ. At Level 0, this duplication is unavoidable — there is no mechanism to abstract over the type. The programmer working exclusively at Level 0 must write and maintain separate implementations for each concrete type, even when the implementations are structurally identical.

This is not a hypothetical limitation. It is the daily reality of C programming, where the absence of parametric polymorphism leads to either code duplication or void* erasure (which abandons type safety). Rust programmers encounter the same limitation whenever they write concrete functions where generic ones would serve — and recognising this pattern is the first step toward understanding why Level 1 exists.

The Simply Typed Lambda Calculus

Level 0 corresponds to λ→, the simply typed lambda calculus. In λ→:

  • Types are built from base types (like i32, bool) and function types (like i32 → bool).
  • Every term has a unique type, determined by the typing rules.
  • There are no type variables, no polymorphism, no type-level computation.
  • Type checking is decidable, straightforward, and fast.

This is the origin of the lambda cube — the vertex where no axes of dependency have been activated. Functions take values and produce values. Types classify values. That is all.

The simply typed lambda calculus has strong properties precisely because of its limitations:

Strong normalisation: every well-typed term in λ→ terminates. There are no infinite loops in a purely simply typed system. (Rust breaks this property with loop {}, recursion, and other general-recursion mechanisms — but the type system at Level 0 does not introduce non-termination. It is the value-level language that adds it.)

Decidable type checking: given a term and a candidate type, it is always possible to determine whether the term has that type. This remains true throughout Rust’s type system, but it is most obviously true at Level 0, where there are no type variables to unify and no bounds to satisfy.

Unique typing: in the simplest form of λ→, every term has exactly one type. Rust relaxes this through coercions and subtyping (a &'static str can be used where a &'a str is expected), but at Level 0 with concrete types, the typing is essentially unique.

These properties make Level 0 the safest, most predictable part of the type system. They also make it the least expressive. The entire purpose of the higher levels is to sacrifice some of this simplicity in exchange for the ability to state more general propositions.

The Distinction in 𝒯 and 𝒱

Level 0 is where the distinction between 𝒯 and 𝒱 is easiest to see — because at this level, the two categories are almost in correspondence.

Consider the type Circle from our earlier example. In 𝒱, it is a block of memory containing an f64. The function Circle::area is a sequence of machine instructions that reads this f64, multiplies it by itself and by π, and writes the result somewhere. Everything is concrete. Everything is bytes and instructions.

In 𝒯, Circle is a named node in the type lattice. It has associated impl blocks that position it relative to traits. If we add impl Display for Circle, then Circle acquires a connection to the Display node. If we add impl Clone for Circle, it acquires a connection to Clone. These connections are logical structure — they say what can be done with a Circle, what propositions it satisfies.

At Level 0, the forgetful functor F: 𝒯 → 𝒱 is nearly trivial:

𝒯F𝒱
Circle (type identity)f64 (memory layout)
impl Display for CircleConcrete fmt function
impl Clone for CircleConcrete clone function
Type identity: Circle ≠ f64(erased — same bytes)

The functor erases type identity (in 𝒱, Circle is just f64) and resolves trait implementations to concrete functions. At Level 0, there is no polymorphism to monomorphise and no lifetimes to erase. The boundary crossing is minimal.

But it is not trivial. The type identity — the fact that Circle is not f64, even though they have the same representation — is real structure in 𝒯 that vanishes in 𝒱. This identity prevents a Circle from being used where a raw f64 is expected, or vice versa. It is a logical distinction with no runtime cost — zero-cost abstraction at its simplest.

What Level 0 Cannot Express

The characteristic limitation of Level 0 is repetition without abstraction. Whenever the same logical pattern applies to multiple types, the Level 0 programmer must restate it for each type individually.

Consider a pattern that arises constantly in practice: formatting a value for display.

#![allow(unused)]
fn main() {
struct Celsius(f64);
struct Fahrenheit(f64);
struct Kelvin(f64);

impl std::fmt::Display for Celsius {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.1}°C", self.0)
    }
}

impl std::fmt::Display for Fahrenheit {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.1}°F", self.0)
    }
}

impl std::fmt::Display for Kelvin {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.1}K", self.0)
    }
}
}

Three impl blocks, each proving Display for a specific type. Each is a ground proof — true for exactly one type. The pattern is obvious: each temperature unit is an f64 with a unit suffix. But at Level 0, there is no way to state this pattern once and apply it to all three types. The abstraction — “any type that wraps an f64 and has a unit suffix can be displayed” — requires the quantification and trait bounds of Levels 1 and 2.

Similarly, consider a function that should work with any of these temperature types:

#![allow(unused)]
fn main() {
struct Celsius(f64);
fn celsius_above_boiling(temp: &Celsius) -> bool {
    temp.0 > 100.0
}
}

This is hopelessly specific. At Level 0, you would need fahrenheit_above_boiling, kelvin_above_boiling, and so on — each with its own threshold, each as a separate function. The observation that “checking whether a temperature exceeds a threshold” is a general pattern cannot be expressed at Level 0.

These limitations motivate the ascent through the levels:

  • Level 1 provides quantification over types: fn identity<T>(x: T) -> T works for all types, not just one.
  • Level 2 provides bounded quantification: fn max<T: Ord>(a: T, b: T) -> T works for all orderable types.
  • Level 3 provides proof construction: impl<T: Display> Printable for T derives proofs from other proofs.
  • Level 4 provides type-level functions: Vec<_> maps types to types.

Each level adds a form of abstraction that eliminates a class of repetition that the level below cannot avoid.

The Level 0 Worldview

Every programming style reflects an implicit position in the level hierarchy. The Level 0 programmer’s worldview has a distinctive character:

Types describe data. A struct is a memory layout. An enum is a tagged union. A function signature specifies the byte-level contract between caller and callee. Types are fundamentally about runtime representation.

Impl blocks add behaviour. Methods are functions attached to types. An impl block gives a type its API — the operations it supports. Traits are interfaces that types can optionally implement.

The compiler checks correctness. Type errors mean you are passing the wrong kind of data. The compiler catches mistakes that would otherwise cause runtime failures.

This worldview is not wrong. It is incomplete. It is the view from 𝒱 — the value category — projected upward through the boundary. The Level 0 programmer sees types as they appear after the forgetful functor has been applied: as data descriptions, with the logical structure stripped away.

The type-theoretical view sees the same types from above the boundary — from 𝒯:

Types are propositions. A struct is a conjunction. An enum is a disjunction. A function type is an implication. Types exist to make formal claims about the program’s domain.

Impl blocks are proofs. They are evidence that a type satisfies a proposition. The compiler does not just “check correctness” — it verifies that your logical claims are substantiated by your code.

Compiler errors are logical feedback. They identify gaps in your reasoning, not just mistakes in your data handling.

The Level 0 worldview and the type-theoretical worldview are not contradictory. They are different views of the same structure — one from 𝒱, the other from 𝒯. At Level 0, the difference is subtle: ground propositions and data descriptions look almost the same, and the boundary barely distorts anything. But as we ascend through the levels, the gap widens. At Level 2, trait bounds are clearly propositions, not data descriptions. At Level 3, blanket impls are clearly proof constructions, not mere “behaviour.” At Level 4, phantom types exist purely in 𝒯, with no 𝒱 counterpart at all.

Level 0 is where the two views are closest together. It is also where most Rust programmers spend most of their time. Understanding what it offers — and where it stops — is the foundation for understanding everything that follows.

Ascending

The value domain is the ground floor of Rust’s type system. It is expressive enough for many programs: concrete types, concrete functions, closed-world disjunctions, and ground proofs cover a vast range of practical software. Much production Rust — configuration parsing, protocol implementation, state machine drivers — lives primarily at Level 0.

But Level 0 is also where you notice the ceilings. The function that could be generic but is not. The impl block that repeats a pattern you have already written for three other types. The enum that models states but does not prevent invalid transitions. Each of these ceilings marks the boundary of what Level 0 can express — and points toward the level that lifts it.

In the next chapter, we ascend to Level 1: the domain of unconstrained parametric polymorphism. There, a single function can operate on all types, a single struct can hold any value, and the type signature alone determines the implementation. We leave the world of ground propositions and enter the world of universal quantification.

Parametric Polymorphism (Level 1)

At Level 0, every type is fixed and every function names its types explicitly. The price of this concreteness is repetition: the same logic, restated for each type it applies to. We ended the previous chapter staring at that ceiling — identical function bodies differing only in the types they mention, identical impl blocks differing only in the types they prove propositions about.

Level 1 lifts this ceiling. It introduces a single new mechanism — the type parameter with no bounds — and in doing so, it moves from the simply typed lambda calculus (λ→) to System F (λ2), Girard and Reynolds’ polymorphic lambda calculus. A function at Level 1 does not operate on a specific type. It operates on all types simultaneously, uniformly, without knowing anything about which type it has been given.

This sounds like freedom. It is, in fact, the opposite. The absence of any constraint on T is itself the most powerful constraint of all: it means the implementation can do almost nothing. And it is precisely this inability — this enforced ignorance of what T is — that gives Level 1 its remarkable properties. At this level, the type signature alone determines the implementation. The type is the theorem, and the theorem admits essentially one proof.

System F: The Formal Foundation

Chapter 1 introduced System F as the second vertex of the lambda cube — the system obtained by adding the axis “terms depending on types” to the simply typed lambda calculus. Let us now examine what this system actually permits.

In System F, a value can be parameterised by a type. The type of such a value is written with a universal quantifier:

∀T. T → T

This reads: “for all types T, a function from T to T.” The ∀ binds the type variable T across the entire expression. A value of this type must be a function that, given any type whatsoever, takes a value of that type and returns a value of the same type.

In Rust:

#![allow(unused)]
fn main() {
fn identity<T>(x: T) -> T {
    x
}
}

The angle-bracket syntax <T> is Rust’s notation for the universal quantifier. The function identity does not have a single type like fn(i32) -> i32. It has a universally quantified type: for every type T, it is a function from T to T. The compiler instantiates this universal at each call site — identity::<i32>, identity::<String>, identity::<Vec<bool>> — but the definition is written once, for all types.

This is the first axis of the lambda cube made concrete: a value (the function) depending on a type (the parameter T). Level 0 had no such mechanism. Level 1 has nothing else.

The Meaning of “No Bounds”

The defining characteristic of Level 1 is the absence of trait bounds on the type parameter. When you write <T> with no : Bound clause, you are making a universal claim with no hypotheses. Under Curry-Howard, this is the proposition ∀T. P(T) — for all types, unconditionally.

What can you do with a value of type T when you know nothing about T?

You cannot compare it. Comparison requires Ord or PartialOrd — that is a bound. You cannot print it. Printing requires Display or Debug — those are bounds. You cannot clone it. Cloning requires Clone — that is a bound. You cannot drop it and construct a new one. Construction requires knowing the type’s structure — but T is opaque. You cannot hash it, serialise it, add it to another value, or test it for equality.

You can do exactly four things with a value of unknown type T:

  1. Move it. Transfer ownership from one binding to another.
  2. Store it. Place it into a generic container — a struct field, a tuple, a Vec.
  3. Return it. Give it back to the caller.
  4. Pass it to another function that also accepts T with no bounds.

These four operations — move, store, return, delegate — are the complete vocabulary of Level 1. They are the operations that require no knowledge of the value whatsoever, only that it exists and has some type.

#![allow(unused)]
fn main() {
fn first_of_two<T>(a: T, _b: T) -> T {
    a
}

fn second_of_two<T>(_a: T, b: T) -> T {
    b
}

fn wrap_in_option<T>(x: T) -> Option<T> {
    Some(x)
}

fn pair<A, B>(a: A, b: B) -> (A, B) {
    (a, b)
}
}

Each of these functions operates at Level 1: fully generic, no bounds, nothing known about the type parameters. Each one can only rearrange, package, or select among its arguments. None can inspect, transform, or create values of the unknown type.

Parametricity: The Constraint of Ignorance

In 1983, John Reynolds formalised the consequences of this enforced ignorance in his paper “Types, Abstraction, and Parametric Polymorphism.” The result — relational parametricity — is the theoretical backbone of Level 1.

The key insight is this: because a Level 1 function knows nothing about its type parameter, it must behave uniformly across all instantiations. It cannot branch on what T is. It cannot special-case T = i32 to do one thing and T = String to do another. The function’s behaviour with respect to T is fixed by its type signature — not by its implementation.

Reynolds stated this precisely using relations. Consider a function f of type ∀T. T → T. Parametricity says: for any two types A and B, and any relation R between values of A and values of B, if a: A and b: B are related by R, then f(a) and f(b) are also related by R.

In other words, f preserves all possible relationships between types. The only function that preserves every possible relationship between its input and output, for every possible type, is the function that does nothing: the identity.

This is why fn identity<T>(x: T) -> T has exactly one sensible implementation. The type ∀T. T → T admits precisely one inhabitant (up to observational equivalence): the function that returns its argument unchanged. Any other behaviour would violate parametricity — it would require distinguishing between types, which the absence of bounds forbids.

A Semi-Formal Argument

Let us see why this is the case without the full relational machinery. Suppose you attempted to write a different implementation:

#![allow(unused)]
fn main() {
fn not_identity<T>(x: T) -> T {
    // What could we write here that differs from `x`?
    // We need to produce a value of type T.
    // We cannot construct a new T — we do not know its structure.
    // We cannot modify x — we have no operations on T.
    // The only T-typed value available is x itself.
    x
}
}

The function must return a T. The only value of type T in scope is x. There is no other way to obtain a T — you cannot conjure one from nothing, because you do not know what T is. So you must return x.

This argument generalises. For any Level 1 function, the implementation is determined (up to choice among available values) by the type signature alone. The types constrain the plumbing so tightly that there is often only one way to connect the inputs to the output.

Theorems for Free

Philip Wadler, building on Reynolds’ parametricity, published “Theorems for Free!” in 1989, demonstrating that the type signature of a polymorphic function implies non-trivial properties of its behaviour — properties that hold without examining the implementation.

Let us derive several such theorems for Rust functions.

Theorem 1: The Identity

Type: fn<T>(T) -> T

Free theorem: The function must return its argument unchanged.

We have already seen why. There is exactly one value of type T in scope, and it must be the return value. The identity function is the only function of this type.

Theorem 2: Constant Selection

Type: fn<T>(T, T) -> T

Free theorem: The function must return one of its arguments — either always the first, or always the second.

#![allow(unused)]
fn main() {
fn choose_first<T>(a: T, _b: T) -> T { a }
fn choose_second<T>(_a: T, b: T) -> T { b }
}

These are the only two implementations. The function cannot construct a new T, cannot combine its arguments (no operations on T), and cannot decide at runtime which to return (that would require inspecting T, which is forbidden). The choice is baked into the code at definition time.

[!Note] A caveat on totality. In Rust, which allows divergence, the precise statement is: if the function returns, it returns one of its arguments, and the choice does not depend on the type T. Parametricity theorems hold modulo termination and effects — they constrain the function’s behaviour under the assumption that it terminates normally. The function may also loop or panic, which are escape routes that parametricity does not rule out.

Theorem 3: List Rearrangement

Type: fn<T>(Vec<T>) -> Vec<T>

Free theorem: The function can only rearrange, duplicate, or drop elements. It cannot create new elements or inspect existing ones.

This is a powerful result. Without reading a single line of implementation, you know from the type alone that a function Vec<T> -> Vec<T> (with no bounds on T) can:

  • Reverse the vector
  • Shuffle it (if it uses external randomness)
  • Take the first N elements
  • Repeat elements
  • Return an empty vector
  • Return the vector unchanged

It cannot:

  • Sort the vector (requires Ord)
  • Deduplicate (requires Eq)
  • Insert a default value (requires Default)
  • Filter by a predicate on elements (requires inspecting T)

Every operation that examines the content of elements requires a bound. Level 1 can only manipulate the structure of the container.

#![allow(unused)]
fn main() {
fn reverse<T>(mut items: Vec<T>) -> Vec<T> {
    items.reverse();
    items
}

fn take_first_two<T>(items: Vec<T>) -> Vec<T> {
    items.into_iter().take(2).collect()
}

fn duplicate_all<T: Clone>(items: Vec<T>) -> Vec<T> {
    // Wait — this requires Clone!
    // This function is Level 2, not Level 1.
    items.iter().cloned().cycle().take(items.len() * 2).collect()
}
}

Notice the trap: duplicate_all appears to be a structural operation, but it requires Clone — a bound — because duplicating a value means producing a second copy, and copying requires knowledge of how to copy. At Level 1, each value of type T is unique and unreproducible. You can move it, but you cannot multiply it. This is a direct consequence of Rust’s ownership semantics interacting with parametricity: without Clone, every T value exists exactly once.

Theorem 4: Pair Manipulation

Type: fn<A, B>((A, B)) -> (B, A)

Free theorem: The function must swap the pair components.

#![allow(unused)]
fn main() {
fn swap<A, B>(pair: (A, B)) -> (B, A) {
    (pair.1, pair.0)
}
}

The output must contain one value of type B and one of type A. The only B available is pair.1; the only A is pair.0. There is exactly one way to construct the output.

Theorem 5: Function Application

Type: fn<A, B>(A, fn(A) -> B) -> B

Free theorem: The function applies its second argument to its first.

#![allow(unused)]
fn main() {
fn apply<A, B>(value: A, f: fn(A) -> B) -> B {
    f(value)
}
}

We must produce a B. We have no B value directly, but we have a function from A to B and a value of type A. The only way to obtain a B is to apply f to value. The implementation is forced.

The Parametricity Boundary

Theorems for free rest on a critical assumption: the function cannot inspect or discriminate on the type parameter. In a language with runtime type inspection — Java’s instanceof, Go’s type switches, C’s void* casting — parametricity breaks down. A function of type ∀T. T → T in Java can check at runtime whether T is String and return a different string.

Rust largely preserves parametricity. A generic function cannot branch on the concrete type of its parameter. There is no instanceof, no runtime type switch, no way to downcast a generic T to a specific type.

But there are exceptions — narrow channels where type identity leaks through:

TypeId::of::<T>() returns a runtime identifier for a type. A function bounded by T: 'static can call TypeId::of::<T>() and compare it against known type IDs:

#![allow(unused)]
fn main() {
use std::any::TypeId;

fn is_string<T: 'static>(_x: &T) -> bool {
    TypeId::of::<T>() == TypeId::of::<String>()
}
}

This function violates parametricity: it distinguishes String from other types at runtime. Note, however, that it requires the 'static bound — it is not a Level 1 function. Pure Level 1 (no bounds at all) preserves parametricity fully. The leak occurs only when the 'static bound opens the TypeId channel.

Specialisation (unstable) would allow different implementations for different type parameters, breaking parametricity by design. It remains a nightly-only feature precisely because of the tension between specialisation and the parametric guarantees that stable Rust preserves.

For the purposes of this chapter, the rule is: at Level 1, with no bounds whatsoever, parametricity holds. The type signature determines the implementation. The theorems are free.

Generic Data Structures at Level 1

Level 1 is not only about functions. It is equally about data structures — generic types that hold values of an unknown type.

#![allow(unused)]
fn main() {
struct Slot<T> {
    value: T,
}

impl<T> Slot<T> {
    fn new(value: T) -> Self {
        Slot { value }
    }

    fn into_inner(self) -> T {
        self.value
    }

    fn replace(&mut self, new_value: T) -> T {
        std::mem::replace(&mut self.value, new_value)
    }
}
}

The impl<T> Slot<T> block is a Level 1 impl: it applies to Slot<T> for all T, with no bounds. Every method in this block can only move, store, or return values of type T. The replace method uses std::mem::replace — itself a Level 1 function in the standard library — to swap the stored value with a new one, returning the old value without ever inspecting it.

This is the essence of a Level 1 container: it is structure without content. A Slot<T> holds a T-shaped hole into which any type fits. The container’s behaviour — storing, retrieving, replacing — is entirely independent of what fills the hole.

The standard library’s most fundamental generic types are Level 1 at their core:

  • Option<T> — a slot that may be empty
  • Vec<T> — a growable sequence of slots
  • Box<T> — a heap-allocated slot
  • (A, B) — a pair of slots

Each of these defines its basic structure — the new, map, unwrap, push, pop operations — at Level 1, with no bounds on the type parameter. Additional operations that require bounds (sorting a Vec, displaying an Option) are added in separate, bounded impl blocks at Level 2. The structural core remains parametric.

Map: The Canonical Level 1 Operation

There is one operation that transcends simple storage and retrieval while remaining at Level 1: mapping. Given a function from T to U, transform a container of T into a container of U:

#![allow(unused)]
fn main() {
struct Slot<T> { value: T }
impl<T> Slot<T> {
    fn map<U>(self, f: impl FnOnce(T) -> U) -> Slot<U> {
        Slot { value: f(self.value) }
    }
}
}

The map operation does not require any bounds on T or U. It takes a function — provided by the caller — and applies it to the contained value. The Slot does not need to know what T is or what U is; it only needs to know that a transformation exists between them.

This is why map appears on Option, Result, Vec, iterators, and virtually every generic container in Rust. It is the fundamental Level 1 transformation: apply a caller-supplied function to a contained value, without the container needing to understand either type.

The parametricity theorem for map says: mapping f then g is the same as mapping their composition g ∘ f. This is the functor law, and it holds for free from the type signature — no implementation inspection needed.

PhantomData: Type-Level Presence, Value-Level Absence

Not every generic parameter needs to appear in a value. Sometimes a type needs to mention a type parameter — to establish a relationship in 𝒯 — without actually containing a value of that type in 𝒱. This is the role of PhantomData<T>.

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

struct Tagged<T, Tag> {
    value: T,
    _tag: PhantomData<Tag>,
}
}

The field _tag has type PhantomData<Tag>. At runtime, PhantomData is a zero-sized type — the forgetful functor maps it to nothing. In 𝒱, Tagged<i32, Metres> and Tagged<i32, Seconds> have identical memory layouts: a single i32. But in 𝒯, they are distinct types. The Tag parameter exists purely above the boundary.

PhantomData is a Level 1 mechanism because it requires no bounds on the phantom parameter. The parameter Tag is unconstrained — it could be any type — and the struct imposes no requirements on it. The phantom parameter’s purpose is identity, not capability.

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

struct Metres;
struct Seconds;

struct Quantity<Unit> {
    value: f64,
    _unit: PhantomData<Unit>,
}

impl<Unit> Quantity<Unit> {
    fn new(value: f64) -> Self {
        Quantity { value, _unit: PhantomData }
    }

    fn scale(self, factor: f64) -> Self {
        Quantity::new(self.value * factor)
    }
}
}

The impl<Unit> Quantity<Unit> block is Level 1: it works for all Unit types, with no bounds. The scale method multiplies the value without knowing or caring what the unit is. The unit parameter prevents type confusion — Quantity<Metres> and Quantity<Seconds> cannot be mixed — but contributes nothing at runtime.

This illustrates a general principle: Level 1 can carry type-level information without runtime cost. The phantom parameter is structure in 𝒯 that the forgetful functor erases completely. It is the simplest form of a pattern that reaches its full expression at Level 4 (typestate), where phantom parameters encode entire state machines above the boundary.

Note, however, that PhantomData has an important role in variance and drop-checking. The compiler treats PhantomData<T> as if the struct logically owns a T, which affects lifetime analysis. This is a Level 1 mechanism with consequences for the lifetime sub-logic — a reminder that the levels interact, even when a concept belongs primarily to one.

The Implicit Bound: Sized

Every type parameter in Rust carries an invisible axiom. When you write <T>, the compiler reads <T: Sized> — an implicit bound asserting that T has a statically known size. This is the only bound that Rust adds without being asked, and it is the only bound that can be removed rather than added.

Sized is a Level 1 proposition: “this type occupies a fixed, known number of bytes.” It is the prerequisite for the forgetful functor — F needs a concrete memory layout to map a type from 𝒯 to 𝒱. A value must have a known size to be placed on the stack, stored in a struct field, or passed by value. Without Sized, the boundary cannot do its work.

Most types satisfy Sized trivially: i32 is 4 bytes, (u8, u64) is 16 bytes (with padding), Option<bool> is 1 byte. But some types are dynamically sized types (DSTs) — types that exist in 𝒯 as valid types but whose size is not known at compile time:

  • [T] — a slice of unknown length
  • str — a string of unknown length
  • dyn Trait — a trait object whose concrete type is erased

These types cannot cross the boundary directly. You cannot write let x: [i32] — the compiler does not know how much stack space to allocate. DSTs must always appear behind indirection: &[T], &str, Box<dyn Trait>. The indirection provides the size information that the DST itself lacks — a pointer (fixed size) plus, for slices, a length, or for trait objects, a vtable pointer.

The syntax ?Sized relaxes the implicit bound. It is unique in Rust: every other bound is something you add to a type parameter, but ?Sized is something you remove. Writing T: ?Sized says “T may or may not be sized” — the function accepts both sized and unsized types.

fn print_ref<T: ?Sized + std::fmt::Display>(value: &T) {
    println!("{value}");
}

fn main() {
    print_ref(&42);                          // T = i32 (Sized)
    print_ref("hello");                       // T = str (!Sized)
    let d: &dyn std::fmt::Display = &42;
    print_ref(d);                             // T = dyn Display (!Sized)
}

The ?Sized bound widens the function’s domain: it ranges over all displayable types, including those that cannot be passed by value. The trade-off is that the function can only accept T behind a reference or pointer — it cannot move, store, or return a T by value, because doing so requires knowing the size.

In the fibration view, Sized partitions the base space. The Sized types form the sub-fibration where F can operate directly. The ?Sized extension includes fibres that F can only reach through indirection. The implicit Sized bound keeps functions in the directly-accessible region by default; ?Sized explicitly opts into the wider space.

Subtyping and Variance

In general type theory, subtyping is a preorder on types: if A <: B, then a value of type A can be used wherever a value of type B is expected. In object-oriented languages, subtyping is pervasive — a Dog can be used where an Animal is expected, because Dog <: Animal. Width subtyping (records with more fields are subtypes of records with fewer) and depth subtyping (records with more specific field types are subtypes) give these systems a rich subtyping lattice.

Rust’s subtyping is far narrower. There is no structural subtyping on structs, no inheritance hierarchy, no implicit widening of types. Rust’s subtyping operates almost exclusively through lifetimes.

Lifetime Subtyping

The subtyping relation in Rust is: if 'long: 'short (the lifetime 'long outlives 'short), then:

&'long T  <:  &'short T

A reference that lives longer can be used where a shorter-lived reference is expected. This is sound because a reference valid for a longer duration is at least as valid as one needed for a shorter duration. The relation is a preorder: reflexive ('a: 'a) and transitive (if 'a: 'b and 'b: 'c, then 'a: 'c).

This is the primary — and nearly the only — subtyping relation in Rust. Unlike Java or C#, where subtyping pervades the type system through class hierarchies, Rust confines subtyping to the lifetime sub-logic. The result is a simpler, more predictable system where subtyping interactions are limited to reference validity.

Variance

Given a type constructor F<T>, variance describes how subtyping on T propagates to subtyping on F<T>. There are three cases:

Covariant: T <: U implies F<T> <: F<U> — subtyping propagates in the same direction. Shared references are covariant: if 'long: 'short, then &'long T <: &'short T. Similarly, Vec<&'long T> <: Vec<&'short T>. The container preserves the subtyping relationship of its contents.

Contravariant: T <: U implies F<U> <: F<T> — subtyping propagates in the reverse direction. Function parameters are contravariant: fn(&'short T) can be used where fn(&'long T) is expected, because a function that handles short-lived references certainly handles long-lived ones too. The direction reverses because the function receives rather than produces the value.

Invariant: no subtyping relationship propagates. &mut T is invariant in T — if &mut T were covariant, you could write a &'short T into a location that promises &'long T, creating a dangling reference. Cell<T> and RefCell<T> are also invariant, for the same reason: interior mutability requires that the type parameter be fixed exactly.

Why &mut T Is Invariant

The invariance of &mut T is not arbitrary — it is the soundness condition for mutable references. Consider what would happen if &mut T were covariant:

fn unsound<'long, 'short>(x: &mut &'long str, y: &'short str)
where
    'long: 'short,
{
    // If &mut T were covariant, this would be allowed:
    // &mut &'long str  <:  &mut &'short str
    // Then we could write:
    *x = y;  // Stores a 'short reference where a 'long reference is expected!
}
// After this function returns, *x contains a reference that may dangle.

Invariance prevents this: &mut &'long str has no subtyping relation with &mut &'short str, so the coercion is rejected. The mutable reference’s invariance preserves the contract that whatever you write through it must be valid for the original lifetime.

PhantomData and Variance Control

When a struct has a type parameter that does not appear in any field — a phantom parameter — PhantomData determines the struct’s variance with respect to that parameter. The choice of PhantomData wrapper signals the intended variance to the compiler:

PhantomData formVariance in TUse case
PhantomData<T>CovariantThe struct logically owns a T
PhantomData<fn(T)>ContravariantThe struct logically consumes T
PhantomData<fn(T) -> T>InvariantThe struct both consumes and produces T

This is how the programmer communicates variance for phantom parameters. The compiler infers variance for parameters that appear in real fields based on their position (owned values: covariant; behind &mut: invariant; in function argument position: contravariant). For phantom parameters, PhantomData is the explicit declaration.

The Categorical View

Variance is a functor property. A covariant type constructor is a covariant functor on the subtyping preorder: it maps the ordering A <: B to F<A> <: F<B>, preserving direction. A contravariant type constructor is a contravariant functor: it reverses the ordering. An invariant type constructor is neither — it does not induce any relationship on the subtyping preorder.

This connects to the functor laws from Chapter 1. The map operation on Option<T>, Vec<T>, and other covariant containers is not merely a convenience — it is the functorial action that witnesses the covariance. The existence of a natural map is precisely what it means for a type constructor to be covariant.

The Fibration View

Chapter 1 introduced the concept of a fibration: a generic type as a family of types indexed by the parameter. At Level 1, this structure is at its simplest.

Consider Vec<T>. The type constructor Vec defines a total space — a family of types parameterised by T:

    Vec<_>
     |
     ├── Vec<i32>       (fibre over i32)
     ├── Vec<String>    (fibre over String)
     ├── Vec<bool>      (fibre over bool)
     ├── Vec<Vec<u8>>   (fibre over Vec<u8>)
     └── ...            (one fibre for every type)

At Level 1, the base space is unrestricted — every type in the language is a valid index. The generic function fn first<T>(items: &[T]) -> Option<&T> operates uniformly across the entire total space. It does not distinguish one fibre from another.

At Level 2, bounds restrict the base space. fn sort<T: Ord>(items: &mut [T]) operates only over the sub-fibration indexed by orderable types. The base space narrows; the available operations widen.

The fibration perspective makes visible what “adding a bound” actually does. It does not add a capability to the function in isolation — it restricts the family of types the function ranges over, and it is this restriction that enables the additional operations. The bound is a predicate on the index set, not a tool given to the implementation.

Level 1 versus Level 2: The Boundary Between Them

The distinction between Level 1 and Level 2 is sharp: Level 1 has no bounds; Level 2 has at least one. This binary difference produces qualitative changes in what can be expressed.

PropertyLevel 1 (no bounds)Level 2 (bounded)
Type parameter knowledgeNothingThe bound’s interface
Available operations on TMove, store, returnBound’s methods
ParametricityFullRestricted to bound-consistent relations
Free theoremsMaximalWeaker (more implementations possible)
Implementation freedomMinimalWider
Base space of fibrationAll typesTypes satisfying the bound

The relationship is an inverse: the more you know about T, the less constrained your implementation, and the fewer theorems follow for free.

Consider these three functions:

fn first_unbounded<T>(items: &[T]) -> Option<&T> {
    items.first()
}

fn first_cloneable<T: Clone>(items: &[T]) -> Option<T> {
    items.first().cloned()
}

fn first_default<T: Default>(items: &[T]) -> T {
    items.first().cloned_or_default()  // does not compile!
}

That last function does not compile — cloned_or_default is not a real method. Let us write it properly:

#![allow(unused)]
fn main() {
fn first_or_default<T: Clone + Default>(items: &[T]) -> T {
    match items.first() {
        Some(v) => v.clone(),
        None => T::default(),
    }
}
}

Each step adds a bound and widens the implementation space:

  • first_unbounded can only return a reference to an existing element, or None. It cannot create values.
  • first_cloneable can return a copy of an element (via Clone), but still cannot create values from nothing.
  • first_or_default can return a copy of an element or construct a default value (via Default), eliminating the Option.

Each additional bound is an additional hypothesis in the Curry-Howard reading. More hypotheses mean a weaker universal statement (it applies to fewer types) but a stronger conclusion (it can do more). This is the lattice structure from Chapter 1: descending in the lattice adds constraints, narrows the domain, and expands the available operations.

Level 1 is the top of this lattice — the point of maximum generality and minimum capability. It is where the free theorems are strongest, where the implementation is most constrained, and where the type signature carries the most information about what the function does.

Type Inference and the Turbofish

When a Level 1 function is called, the compiler must determine the concrete type for each type parameter — it must recover 𝒯 information from 𝒱 context. This is type inference: a partial inverse of the forgetful functor, reconstructing erased type structure from the constraints imposed by how a value is used.

In most cases, inference succeeds without intervention. The compiler examines the argument types, the expected return type, and the operations performed on the result, and deduces the type parameter uniquely:

#![allow(unused)]
fn main() {
let items = vec![1, 2, 3]; // inferred: Vec<i32>
let first = items.first();  // inferred: Option<&i32>
}

But inference sometimes faces ambiguity — multiple types could satisfy the constraints. The turbofish syntax ::<> lets the programmer supply the missing type information explicitly:

#![allow(unused)]
fn main() {
let x: Vec<i32> = vec![1, 2, 3].into_iter().collect();
// equivalently:
let y = vec![1, 2, 3].into_iter().collect::<Vec<i32>>();
}

Both forms provide the same 𝒯 information through different channels: a type annotation on the binding, or a turbofish on the call. The turbofish is needed when the 𝒱 context does not uniquely determine the type parameter — when the partial inverse of F is not uniquely defined. Where the usage context resolves the ambiguity, the compiler fills in the type silently; where it does not, the programmer must intervene.

What Level 1 Reveals

The Level 0 programmer sees types as data descriptions. The Level 1 programmer sees something different: types as specifications.

At Level 1, the type signature is not merely a contract about data layout. It is a near-complete specification of behaviour. When you read fn<T>(T, T) -> T, you know the function selects one of its arguments — not because you have read the implementation, but because the type leaves no other possibility. The type is the specification; the implementation is determined.

This is the practical content of Reynolds’ parametricity for the working Rust programmer:

Generic code is constrained code. The more generic your function, the fewer things it can do, and the more you can reason about it from its signature alone. This is the opposite of the naïve expectation that generics provide freedom — in fact, they provide discipline.

Bounds are not restrictions on the caller; they are permissions for the implementation. When you add T: Ord to a function signature, you are not “restricting which types can be passed.” You are granting the implementation permission to compare values. Without that permission, comparison is impossible. The bound is a grant, not a constraint.

The type signature is documentation. At Level 1, the signature tells you nearly everything. A function fn<T>(Vec<T>) -> Vec<T> rearranges elements. A function fn<A, B>(A, fn(A) -> B) -> B applies a function. A function fn<T>(T) -> (T, T) — wait, that requires Clone. The type reveals what is needed.

This perspective also clarifies why Rust’s approach to generics produces better-documented APIs than, say, C++ templates. In C++, a template function’s requirements on its type parameters are implicit — discovered through compilation errors when requirements are not met. In Rust, the bounds are stated explicitly in the type signature. The signature is the specification, stated upfront, verified by the compiler, visible to every reader.

The Boundary at Level 1

When a Level 1 function crosses the boundary, the forgetful functor F performs monomorphisation: the generic family is collapsed into a set of concrete functions, one for each type the program actually uses.

fn wrap<T>(x: T) -> Option<T> {
    Some(x)
}

fn main() {
    let a = wrap(42_i32);      // generates wrap::<i32>
    let b = wrap("hello");     // generates wrap::<&str>
    let c = wrap(vec![1, 2]);  // generates wrap::<Vec<i32>>
}

In 𝒯, wrap is a single universally quantified function — one object in the type category. In 𝒱, after F is applied, it becomes three separate concrete functions, each with a fixed type. The universal quantification has been resolved; the polymorphism has been eliminated. Each monomorphised instance is a Level 0 function — a ground proof with no generics.

This is the boundary’s role at Level 1: it transforms universal statements into collections of ground instances. The universal proposition ∀T. T → Option<T> becomes the set of ground propositions { i32 → Option<i32>, &str → Option<&str>, Vec<i32> → Option<Vec<i32>>, … }. The universal is verified once, above the boundary. Below the boundary, only its instances exist.

The cost model is clear: each monomorphisation produces a separate copy of the function’s machine code. This is the runtime cost of Level 1 — not in execution speed (each instance is as fast as a hand-written Level 0 function) but in code size. A heavily generic program can produce many monomorphisations. The boundary does not add overhead per call, but it may add overhead in binary size. This is the trade-off Rust accepts: zero-cost dispatch in exchange for potential code duplication.

Ascending Further

Level 1 is the domain of pure structure. Functions at this level are plumbing — they route, package, and rearrange values without examining them. The parametricity theorem ensures that this structural manipulation is all they can do, and the free theorems let you reason about them from their types alone.

But most interesting programs need to do more than rearrange. They need to compare, transform, combine, and display values — operations that require knowing something about the type. The moment you write T: Ord, or T: Display, or even T: Clone, you have left Level 1 and entered Level 2.

The transition is not a leap but a single step: the addition of one bound. Yet that single bound changes everything. The fibration narrows. The free theorems weaken. The implementation gains capabilities. And the function, instead of stating “for all types, unconditionally,” begins to state “for all types satisfying this proposition” — a conditional universal, with hypotheses that must be discharged by proof.

In the next chapter, we examine what those hypotheses mean, how they interact, and what structure they form.

Constrained Generics (Level 2)

Level 1 gave us universal quantification — statements about all types, unconditionally. The price was severe: knowing nothing about T, we could do almost nothing with it. Move, store, return. The free theorems were powerful precisely because the implementation space was narrow.

Level 2 changes the terms of the bargain. By adding a single trait bound — T: Ord, T: Display, T: Clone — we restrict the domain of the universal quantifier to those types satisfying a predicate. In exchange, the implementation gains access to the operations that predicate guarantees. The quantification is no longer “for all types” but “for all types such that.”

In the lambda cube, this does not correspond to a new vertex — bounded quantification is orthogonal to the cube’s three axes. Rather, it extends System F with qualified types: universals whose type variable is restricted by a predicate. In Curry-Howard terms, we move from unconditional universals to conditional ones — universal statements with hypotheses. The function signature becomes not just a theorem but a theorem with premises, and the bounds are those premises.

Most Rust generics live at Level 2. The moment you write T: Ord, T: Iterator, or T: Into<String>, you have entered this level. Understanding its structure — the lattice of constraints, the interplay of bounds, the role of associated types — is understanding the layer where Rust programmers spend most of their generic programming time.

Bounds as Predicates

At Level 1, the type parameter T ranges over the entire type universe — every type in the language is a valid instantiation. At Level 2, a bound restricts this range. The bound is a predicate: a proposition that must hold for each type in the parameter domain.

#![allow(unused)]
fn main() {
fn maximum<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}
}

The bound T: Ord is the predicate Ord(T): “T possesses a total ordering.” The parameter domain is no longer “all types” but “all types for which Ord(T) holds” — the set { T | Ord(T) }. Every type in this set has a guaranteed comparison operation, and the function body uses it.

Under Curry-Howard, the signature reads:

∀T. Ord(T) → (T → T → T)

For all types T, if T is ordered, then given two T values, a T value can be produced. The bound is the hypothesis; the function body is the proof; the impl Ord for ConcreteType that the caller must provide is the evidence discharging the hypothesis at the call site.

This is the fundamental structure of Level 2: conditional universal quantification. Every bounded generic function is an if-then statement, universally quantified over the types satisfying its conditions.

What a Bound Grants

A bound does two things simultaneously:

It restricts the domain. Fewer types satisfy T: Ord than satisfy no bound at all. The set of valid instantiations shrinks.

It enables operations. Within the function body, the bound’s methods become available. T: Ord grants access to cmp, max, min, and the comparison operators. T: Display grants fmt and, by extension, to_string(). T: Clone grants clone().

These two effects are inseparable — they are the same mechanism viewed from different angles. The bound restricts the domain to types that have an ordering, and because every type in the restricted domain has an ordering, the ordering operations are safe to use. The restriction is the enablement.

#![allow(unused)]
fn main() {
fn print_max<T: Ord + std::fmt::Display>(a: T, b: T) {
    let winner = if a >= b { a } else { b };
    println!("maximum: {winner}");
}
}

Here, T: Ord + Display grants both comparison (>=) and formatting (println!). Remove either bound and the corresponding operation becomes unavailable — not as a “restriction” imposed on the programmer, but because the predicate no longer guarantees the operation’s existence.

The Constraint Lattice

Chapter 1 introduced the type lattice: the partial order on trait bounds where adding constraints moves down (narrower domain, more operations) and removing constraints moves up (wider domain, fewer operations). At Level 2, we can examine this lattice in detail.

The Partial Order

Given two bounds A and B, we say A ≤ B (A is weaker than B) when every type satisfying B also satisfies A. Equivalently: the predicate region of B is contained within the predicate region of A. The weaker bound admits more types but grants fewer operations.

         (no bounds)           ← Level 1: all types, no operations on T
          /    |    \
       Clone  Ord  Display     ← single bounds: large predicate regions
          \    |    /
       Clone + Ord             ← compound: intersection of regions
            |
     Clone + Ord + Display
            |
          ...
            |
     (concrete type)           ← Level 0: one type, all its operations

The top of the lattice is Level 1 — the unconstrained parameter, satisfying no predicates, admitting all types. The bottom consists of concrete types — Level 0 — where the type is fully determined and all its specific operations are available. Level 2 occupies the vast middle ground between these extremes.

Intersection of Predicate Regions

Each trait bound defines a predicate region in the type lattice — the set of all types satisfying that bound. When you write a compound bound T: A + B, you are taking the intersection of the predicate regions for A and B.

Consider the standard library traits Clone, Ord, and Hash:

    Clone region: { i32, String, Vec<T>, bool, ... }     ← types implementing Clone
    Ord region:   { i32, String, bool, char, ... }        ← types implementing Ord
    Hash region:  { i32, String, bool, char, ... }        ← types implementing Hash

    Clone + Ord:  { i32, String, bool, char, ... }        ← intersection
    Clone + Ord + Hash: { i32, String, bool, char, ... }  ← smaller intersection

Each + narrows the set. A type must satisfy all the predicates simultaneously to lie within the compound region. Under Curry-Howard, the + operator is logical conjunction: T: Clone + Ord is the proposition Clone(T) ∧ Ord(T).

This has a practical consequence for API design: every bound you add to a function signature narrows the set of types that can call it. A function bounded by T: Display accepts more types than one bounded by T: Display + Debug + Clone. The right number of bounds is the minimum needed to implement the function — any additional bound is an unnecessary hypothesis, a premise that the proof does not use.

#![allow(unused)]
fn main() {
// Too many bounds — Hash is never used in the body.
fn print_sorted_bad<T: Ord + std::fmt::Display + std::hash::Hash>(
    items: &mut Vec<T>,
) {
    items.sort();
    for item in items.iter() {
        println!("{item}");
    }
}

// Correct — only the bounds actually used.
fn print_sorted<T: Ord + std::fmt::Display>(items: &mut Vec<T>) {
    items.sort();
    for item in items.iter() {
        println!("{item}");
    }
}
}

The Hash bound in the first version narrows the predicate region unnecessarily. No operation in the body requires hashing. The bound is a vacuous hypothesis — logically valid but wasteful. The second version states the minimal premises, admitting the broadest useful set of types.

Supertraits as Implication

Some traits carry implicit additional bounds through supertrait relationships. A trait declaration trait A: B asserts that every type satisfying A also satisfies B — the proposition ∀T. A(T) → B(T).

#![allow(unused)]
fn main() {
use std::fmt;
trait Report: fmt::Display {
    fn header(&self) -> &str;
}
}

The supertrait bound Report: Display means that any type implementing Report must also implement Display. A function bounded by T: Report gets access to both Report’s methods and Display’s methods, even though only Report appears in the bound:

#![allow(unused)]
fn main() {
use std::fmt;
trait Report: fmt::Display { fn header(&self) -> &str; }
fn print_report<T: Report>(item: &T) {
    println!("=== {} ===", item.header());
    println!("{item}");  // Display is available via the supertrait
}
}

In lattice terms, the Report region is contained within the Display region. Bounding by Report implicitly bounds by Display. The supertrait establishes a refinement relationship: Report is a stronger proposition that implies Display.

The standard library uses this extensively. Eq requires PartialEq. PartialOrd also requires PartialEq. Ord requires both Eq and PartialOrd. The result is a diamond of supertrait implications:

    PartialEq
     ↑       ↑
    Eq    PartialOrd
      ↖     ↗
       Ord

Bounding by Ord grants access to the entire diamond. A function bounded by T: Ord can use ==, !=, <, >, <=, >=, cmp, max, and min — all the operations from Ord and every trait it implies. The single bound Ord is, in reality, a compound proposition: Ord(T) implies PartialOrd(T) ∧ Eq(T) ∧ PartialEq(T).

Bounds and the Impl Domain

At Level 2, we begin to see clearly the relationship between bounds and the proof domain that Chapter 6 will examine in full.

A bound is a demand. An impl block is a supply. The bound T: Ord in a function signature demands that whoever calls this function must supply evidence that their concrete type satisfies Ord. That evidence is the impl Ord for ConcreteType block.

#![allow(unused)]
fn main() {
struct Metres(f64);

impl PartialEq for Metres {
    fn eq(&self, other: &Self) -> bool {
        self.0 == other.0
    }
}

impl Eq for Metres {}

impl PartialOrd for Metres {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Metres {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.total_cmp(&other.0)
    }
}
}

These four impl blocks are proof terms — evidence that Metres satisfies Ord (and its supertraits). Together, they discharge the obligation that a function like maximum::<Metres> demands. Without them, the call site is a gap in the proof: the hypothesis is stated but unsubstantiated.

The relationship is:

  • Level 2 states the propositions (trait bounds in function signatures).
  • Level 3 supplies the proofs (impl blocks).

Level 2 cannot function without Level 3. A bounded generic function is a theorem with hypotheses, and hypotheses require evidence. The programmer working at Level 2 is stating what they need; the programmer working at Level 3 is providing it. In practice, the same programmer often does both — writing bounded functions and the impl blocks that satisfy those bounds — but the two activities belong to different levels of the model.

Where Clauses

The inline bound syntax T: Ord + Display handles simple cases. For more complex propositions — those involving multiple type parameters, associated types, or relationships between types — Rust provides where clauses.

Basic Where Clauses

A where clause moves the bounds out of the angle brackets into a separate block:

#![allow(unused)]
fn main() {
fn print_all<T>(items: &[T])
where
    T: std::fmt::Display,
{
    for item in items {
        println!("{item}");
    }
}
}

This is syntactically equivalent to fn print_all<T: Display>(items: &[T]). The where clause adds no expressive power in this simple case — it is a formatting choice.

Relational Bounds

Where clauses become essential when the proposition involves relationships between type parameters:

#![allow(unused)]
fn main() {
fn convert_all<I, T, U>(iter: I) -> Vec<U>
where
    I: Iterator<Item = T>,
    T: Into<U>,
{
    iter.map(|item| item.into()).collect()
}
}

The where clause states two hypotheses:

  1. I is an iterator yielding values of type T.
  2. T is convertible into U.

These hypotheses are interdependent — the associated type Item connects I to T, and the Into bound connects T to U. The where clause is the natural place to express such multi-parameter propositions, because the relationships span across parameters in a way that inline syntax cannot cleanly capture.

Under Curry-Howard, the where clause is a conjunction of hypotheses:

∀I, T, U. (Iterator(I, Item=T) ∧ Into(T, U)) → (I → Vec<U>)

Each line of the where clause is a separate conjunct. The function body must use all of them — map relies on Into, and Iterator provides the map method itself.

Bounds on Associated Types

Where clauses can constrain not just type parameters but their associated types:

#![allow(unused)]
fn main() {
fn sum_iter<I>(iter: I) -> i64
where
    I: Iterator<Item = i64>,
{
    iter.sum()
}
}

The bound I: Iterator<Item = i64> is a compound proposition: “I is an iterator, and its associated type Item equals i64.” The equality constraint on the associated type is a proposition about a type-level functional dependency — a topic we will examine shortly.

A more general version constrains the associated type with its own bound rather than fixing it to a specific type:

#![allow(unused)]
fn main() {
fn sum_generic<I>(iter: I) -> I::Item
where
    I: Iterator,
    I::Item: std::iter::Sum,
{
    iter.sum()
}
}

Here, I::Item is not fixed — it can be any type that implements Sum. The where clause expresses two propositions: I is an iterator, and I’s item type supports summation. The return type I::Item depends on the type parameter I, making this an example of the codomain depending on the parameter — a topic we take up at the end of this chapter.

Associated Types as Functional Dependencies

An associated type defines a type-level function from the implementing type to another type. When a trait declares type Item;, it establishes that for each type implementing the trait, there is exactly one corresponding Item type.

#![allow(unused)]
fn main() {
trait Container {
    type Element;

    fn first(&self) -> Option<&Self::Element>;
    fn len(&self) -> usize;
}
}

For any type C implementing Container, the associated type C::Element is determined by C. It is a function in 𝒯: given the input type C, the output type C::Element is fixed. This is a functional dependency — knowing C tells you Element.

#![allow(unused)]
fn main() {
trait Container {
    type Element;
    fn first(&self) -> Option<&Self::Element>;
    fn len(&self) -> usize;
}
struct IntBuffer {
    data: Vec<i32>,
}

impl Container for IntBuffer {
    type Element = i32;

    fn first(&self) -> Option<&i32> {
        self.data.first()
    }

    fn len(&self) -> usize {
        self.data.len()
    }
}
}

The impl block establishes: Container(IntBuffer) with Element = i32. The associated type is not a free parameter — it is determined by the impl. For IntBuffer, the element type is always i32. There is no choice; the functional dependency is total.

Associated Types versus Type Parameters

A trait could use a type parameter instead of an associated type:

#![allow(unused)]
fn main() {
trait ContainerOf<E> {
    fn first(&self) -> Option<&E>;
    fn len(&self) -> usize;
}
}

The difference is logical. With an associated type, the relationship is functional: each implementing type determines exactly one element type. With a type parameter, the relationship is relational: a single type might implement ContainerOf<i32> and ContainerOf<String>, being a container of both simultaneously.

In lattice terms:

  • An associated type creates a function in 𝒯: one input, one output, no ambiguity.
  • A type parameter on the trait creates a relation in 𝒯: one input, potentially many outputs.

Rust’s Iterator uses an associated type because an iterator yields exactly one kind of item. From<T> uses a type parameter because a single type can be constructed from many different source types — String implements both From<&str> and From<Vec<u8>>.

The choice between associated types and type parameters is a logical design decision: is the relationship between the implementing type and the dependent type a function (one-to-one) or a relation (one-to-many)?

Chains of Functional Dependencies

Associated types can form chains of dependencies:

#![allow(unused)]
fn main() {
trait Process {
    type Input;
    type Output;
    type Error;

    fn run(&self, input: Self::Input) -> Result<Self::Output, Self::Error>;
}
}

For any type P: Process, the triple (P::Input, P::Output, P::Error) is determined by P. This is a type-level function from one type to three types — or equivalently, a function into a product type in 𝒯.

When you write a generic function over Process:

#![allow(unused)]
fn main() {
trait Process {
    type Input;
    type Output;
    type Error;
    fn run(&self, input: Self::Input) -> Result<Self::Output, Self::Error>;
}
fn run_with_default<P>(proc: &P) -> Result<P::Output, P::Error>
where
    P: Process,
    P::Input: Default,
{
    proc.run(P::Input::default())
}
}

The where clause states: P is a process, and P’s input type has a default value. The second hypothesis is a bound on the output of a type-level function — a proposition about a derived type, not about P directly. This is a second-order constraint: a predicate not on the parameter itself but on a type computed from the parameter.

The Codomain Depending on the Parameter

One of Level 2’s most distinctive features is that the return type of a generic function can depend on the same type parameter as the input. At Level 1, this happens trivially — fn identity<T>(x: T) -> T returns the same type it receives. At Level 2, the dependency becomes substantive through associated types.

#![allow(unused)]
fn main() {
use std::str::FromStr;

fn parse_or_default<T>(input: &str) -> T
where
    T: FromStr + Default,
{
    input.parse().unwrap_or_default()
}
}

The return type T depends on the type parameter, which the caller chooses. But the caller’s choice is constrained: T must satisfy both FromStr and Default. The function’s behaviour — what it parses, what default it falls back to — is entirely determined by the caller’s choice of T.

use std::str::FromStr;
fn parse_or_default<T>(input: &str) -> T
where T: FromStr + Default {
    input.parse().unwrap_or_default()
}
fn main() {
    let n: i32 = parse_or_default("42");        // parses as i32, default 0
    let s: String = parse_or_default("hello");   // parses as String, default ""
    let b: bool = parse_or_default("invalid");   // parse fails, default false
}

Each call instantiates the same function with a different type, producing different behaviour — not because the function branches on T, but because the proofs (the impl blocks for FromStr and Default) differ for each type. The function’s logic is uniform; the variation comes from the proof witnesses, which are resolved at compile time and erased at the boundary.

Associated Types in Return Position

When the return type involves an associated type, the dependency becomes indirect:

#![allow(unused)]
fn main() {
trait Produce {
    type Output;
    fn produce(&self) -> Self::Output;
}

struct Counter(u32);

impl Produce for Counter {
    type Output = u32;
    fn produce(&self) -> u32 {
        self.0
    }
}

struct Greeter;

impl Produce for Greeter {
    type Output = String;
    fn produce(&self) -> String {
        "hello".to_string()
    }
}

fn produce_twice<P: Produce>(p: &P) -> (P::Output, P::Output)
where
    P::Output: Clone,
{
    let first = p.produce();
    let second = first.clone();
    (first, second)
}
}

The return type (P::Output, P::Output) depends on P through the functional dependency of the associated type. For Counter, the return type is (u32, u32). For Greeter, it is (String, String). The compiler resolves this at monomorphisation time — in 𝒯, the function has a family of return types; in 𝒱, after the boundary, each instance has a fixed concrete return type.

This is where Level 2 begins to approach the expressiveness of dependent types — not fully, because the dependency is on a type parameter, not a value parameter, but enough to allow the return type of a function to vary with its input type in a structured, type-safe way.

Negative Reasoning: What Bounds Exclude

Bounds at Level 2 enable operations by restricting the domain. But they also carry implicit negative information — things the function cannot do because a bound is absent.

Consider a function bounded only by T: Clone:

#![allow(unused)]
fn main() {
fn duplicate<T: Clone>(x: &T) -> (T, T) {
    (x.clone(), x.clone())
}
}

This function can clone, but it cannot:

  • Compare the two copies (requires PartialEq)
  • Print them (requires Display or Debug)
  • Sort a collection of them (requires Ord)
  • Hash them (requires Hash)

The absence of each bound is the absence of a hypothesis. Without the hypothesis, the corresponding operations are unavailable, and the corresponding free theorems hold. For example, parametricity guarantees that duplicate treats the cloned values uniformly — it cannot distinguish them, because it has no equality test.

This connects to a design principle: state only the hypotheses you use. Each unused bound is a missed free theorem — a property of your function that holds but is obscured by the unnecessary constraint. The minimal bound set maximises both the function’s applicability (more types can call it) and its guarantees (more free theorems hold).

#![allow(unused)]
fn main() {
// This version promises too little — the Debug bound is unused.
fn duplicate_verbose<T: Clone + std::fmt::Debug>(x: &T) -> (T, T) {
    (x.clone(), x.clone())
}

// This version is strictly better — same behaviour, broader applicability.
fn duplicate_clean<T: Clone>(x: &T) -> (T, T) {
    (x.clone(), x.clone())
}
}

Both functions behave identically, but duplicate_clean makes a stronger promise: it works for any cloneable type, not just debuggable cloneable types. The additional Debug bound in duplicate_verbose restricts the domain without justification.

Worked Example: A Bounded Pipeline

Let us trace a realistic example through the Level 2 lens, reading each component as a proposition and proof.

#![allow(unused)]
fn main() {
use std::fmt;

trait Metric: fmt::Display + PartialOrd {
    fn zero() -> Self;
    fn combine(&self, other: &Self) -> Self;
}

fn best_of<M: Metric>(readings: &[M]) -> Option<String> {
    let mut best = readings.first()?;
    for reading in &readings[1..] {
        if reading > best {
            best = reading;
        }
    }
    Some(format!("best: {best}"))
}
}

Read this under Curry-Howard:

The trait Metric is a compound proposition with supertraits: Metric(T) implies Display(T) ∧ PartialOrd(T). It also asserts two additional capabilities: the existence of a zero value and a combination operation.

The function signature fn best_of<M: Metric>(readings: &[M]) -> Option<String> reads:

∀M. Metric(M) → (&[M] → Option<String>)

For all types M, if M is a metric, then given a slice of M values, an optional string can be produced.

The function body is the proof:

  1. readings.first()? — uses slice operations (always available) and the ? operator on Option (handling the empty-slice case by returning None).
  2. reading > best — uses the comparison operation granted by PartialOrd, which is available because Metric: PartialOrd.
  3. format!("best: {best}") — uses the formatting operation granted by Display, which is available because Metric: Display.

Every operation in the body is justified by a bound. The proof is valid because every step follows from the stated hypotheses. If you removed PartialOrd from the supertrait list, the comparison reading > best would fail — the proof step would reference an unavailable hypothesis. If you removed Display, the format macro would fail.

The Lattice in Practice

The constraint lattice is not merely a theoretical construct. It has direct practical implications for API design.

Finding the Right Altitude

When designing a generic function, the question is: where in the lattice should this function live? Too high (too few bounds) and the function cannot do its job. Too low (too many bounds) and the function excludes types unnecessarily.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::hash::Hash;

// Too high — cannot compile, missing bounds for HashMap.
// fn count_unique_bad<T>(items: Vec<T>) -> usize {
//     let set: HashMap<T, ()> = items.into_iter().map(|x| (x, ())).collect();
//     set.len()
// }

// Right altitude — exactly the bounds HashMap requires.
fn count_unique<T: Eq + Hash>(items: Vec<T>) -> usize {
    let set: HashMap<T, ()> = items.into_iter().map(|x| (x, ())).collect();
    set.len()
}
}

The bounds Eq + Hash are the minimum for HashMap keys. Adding Ord or Display would narrow the domain without benefit. The function sits at precisely the right altitude in the lattice.

Bound Propagation

When a function calls another bounded function, the bounds propagate upward:

#![allow(unused)]
fn main() {
fn max_of_three<T: Ord>(a: T, b: T, c: T) -> T {
    std::cmp::max(std::cmp::max(a, b), c)
}
}

The function calls std::cmp::max, which requires T: Ord. Therefore max_of_three must also require T: Ord — it inherits the bounds of the functions it calls. This is bound propagation: the hypotheses required by sub-proofs propagate to the enclosing proof.

In larger codebases, bound propagation can lead to bound accumulation — functions deep in the call stack requiring many bounds because they transitively call many bounded functions. This is a signal that the function operates at a low altitude in the lattice, with a narrow predicate region. Sometimes this is necessary; sometimes it indicates that the function is doing too much, and should be decomposed into smaller, less-constrained pieces.

The Standard Library’s Altitude Choices

The standard library’s API design is a study in lattice navigation. Consider Vec<T>:

// Level 1 — no bounds
impl<T> Vec<T> {
    fn new() -> Vec<T> { ... }
    fn push(&mut self, value: T) { ... }
    fn pop(&mut self) -> Option<T> { ... }
    fn len(&self) -> usize { ... }
}

// Level 2 — Clone bound
impl<T: Clone> Vec<T> {
    fn extend_from_slice(&mut self, other: &[T]) { ... }
}

// Level 2 — PartialEq bound
impl<T: PartialEq> Vec<T> {
    fn contains(&self, x: &T) -> bool { ... }
    fn dedup(&mut self) { ... }
}

Each impl block sits at a different altitude. The structural operations (push, pop, len) require no bounds — they are Level 1. Operations involving duplication require Clone. Operations involving comparison require PartialEq. The separation ensures that Vec<T> is maximally usable: you get the structural operations for free, and each additional capability becomes available only when the type parameter supports it.

This is the lattice made into API design: each impl block declares the minimum predicate region for its methods, and the overall API is a stack of capabilities, each layer requiring slightly more from the type parameter and offering slightly more in return.

Existential Quantification at Level 2

Chapter 2 introduced impl Trait in return position as existential quantification. At Level 2, existential return types interact with bounds to create powerful abstractions.

#![allow(unused)]
fn main() {
fn make_repeater(n: u32) -> impl Iterator<Item = u32> {
    std::iter::repeat(n)
}
}

The return type says: “there exists a type satisfying Iterator<Item = u32>, and this function returns it.” The caller knows the bound but not the concrete type. The concrete type (std::iter::Repeat<u32>) is hidden — existentially quantified away.

The combination of universal input parameters and existential return types creates functions that are specific in what they demand and abstract in what they provide:

#![allow(unused)]
fn main() {
fn evens_from<I>(iter: I) -> impl Iterator<Item = I::Item>
where
    I: Iterator,
    I::Item: Copy + std::ops::Rem<Output = I::Item> + PartialEq + From<u8>,
{
    iter.filter(|x| *x % I::Item::from(2u8) == I::Item::from(0u8))
}
}

The function universally quantifies over the input iterator type (the caller chooses) and existentially quantifies over the output iterator type (the callee chooses). The bounds on I::Item are the hypotheses enabling the filtering operation. The caller sees only the interface; the implementation details of the returned iterator are hidden above the boundary.

This interplay between ∀ (input) and ∃ (output) is characteristic of Level 2. At Level 1, there was no meaningful use of existential return types — without bounds, the returned type could not be used for anything. At Level 2, the bound on the existential gives the caller enough information to work with the opaque value.

Higher-Ranked Trait Bounds

Ordinarily, lifetime parameters in a function signature are chosen by the caller. When you write fn apply<'a, F: Fn(&'a str) -> &'a str>(f: F), the caller picks a specific lifetime 'a, and the closure F must work for that one lifetime. But sometimes we need a stronger guarantee: the closure must work for all lifetimes, not just one chosen in advance.

This is the role of higher-ranked trait bounds (HRTBs). The syntax for<'a> introduces universal quantification over a lifetime inside a bound:

#![allow(unused)]
fn main() {
fn apply<F>(f: F) -> String
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    let owned = String::from("hello");
    f(&owned).to_string()
}
}

The bound F: for<'a> Fn(&'a str) -> &'a str says: for every lifetime 'a, F can take a &'a str and return a &'a str. The closure does not commit to one lifetime — it promises to work for all of them. This is essential here because the reference &owned has a lifetime local to apply’s body, which the caller cannot name.

Contrast the two forms:

// Caller chooses 'a — the closure works for one specific lifetime.
fn apply_one<'a, F: Fn(&'a str) -> &'a str>(f: F, s: &'a str) -> &'a str {
    f(s)
}

// Closure works for ALL lifetimes — universally quantified inside the bound.
fn apply_all<F>(f: F) -> String
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    let local = String::from("world");
    f(&local).to_string()
}

In the first version, 'a is a parameter of the function — the caller picks it. In the second, 'a is quantified within the bound itself — the closure must handle whatever lifetime it encounters.

Under Curry-Howard, this is second-order quantification nested inside a first-order constraint. The function’s outer quantification is over the type F; the inner quantification, for<'a>, is over lifetimes within the bound on F. This nesting — ∀F. (∀’a. Fn(&’a str) → &’a str)(F) → String — gives where clauses expressive power beyond what simple lifetime parameters can achieve.

In practice, HRTBs are more common than they appear. When you write a closure bound involving references with elided lifetimes — F: Fn(&str) -> &str — the compiler desugars it to F: for<'a> Fn(&'a str) -> &'a str. Lifetime elision in closure bounds is syntactic sugar for higher-ranked quantification. Most Rust programmers use HRTBs regularly without writing for<'a> explicitly.

HRTBs are essential when a function must create references with lifetimes that are not known to the caller — internal borrows, temporary allocations, or iterator adaptors that borrow from their environment. They extend Level 2’s expressiveness by allowing lifetime universals to appear in positions that ordinary lifetime parameters cannot reach.

Parametricity at Level 2

Chapter 4 established that Level 1 functions obey full parametricity — they preserve all relations on their type parameters. At Level 2, parametricity is weakened but not destroyed.

A Level 2 function must preserve all relations consistent with its bounds. The bound restricts the class of relations, and the implementation may exploit operations provided by the bound. But it still cannot distinguish between specific types within the bounded region.

Consider:

#![allow(unused)]
fn main() {
fn largest<T: Ord>(items: &[T]) -> Option<&T> {
    items.iter().max()
}
}

Parametricity at Level 2 says: for any two types A: Ord and B: Ord, and any order-preserving relation R between A and B, applying largest commutes with R. The function treats all orderable types uniformly — it cannot distinguish i32 from String from Metres, as long as each is ordered.

The free theorem is weaker than at Level 1. At Level 1, a function fn<T>(&[T]) -> Option<&T> can only select an element by position (first, last, arbitrary fixed position). At Level 2, with T: Ord, it can select the largest or smallest — the bound grants access to the ordering, and the parametricity theorem must account for it. The implementation space is wider, so the type tells you less about the behaviour. But it still tells you something: the function selects an element based on the ordering, and nothing else.

The View from Level 2

Level 2 is where most Rust programmers live when they write generic code. It is the sweet spot of the model — enough abstraction to avoid repetition, enough constraint to perform useful operations, and enough structure to reason about.

From Level 2, the landscape becomes visible:

Downward lies Level 0 — the concrete ground where every type is fixed and every operation is specific. Level 0 is where the generic function ultimately arrives, after monomorphisation collapses the family into its fibres.

Upward lies Level 1 — the rarefied domain of pure structure, where nothing is known about the type and the free theorems are absolute. Level 1 is the limiting case of Level 2: what remains when you remove all bounds.

Laterally lies Level 3 — the proof domain. Level 2 states propositions; Level 3 supplies proofs. The bounds in a Level 2 function signature are demands that Level 3’s impl blocks must satisfy. The two levels are complementary: predicates and witnesses, hypotheses and evidence.

The programmer working at Level 2 is making conditional universal claims — “for all types satisfying these predicates, this property holds” — and relying on the proof domain to ensure that the predicates are substantiated for each concrete type. The next chapter examines that proof domain in its own right: the space of impl blocks, blanket impls, coherence, and the structure that makes Rust’s proof system consistent.

The Proof Domain (Level 3)

The previous chapters examined types as propositions and trait bounds as predicates. Level 2 showed how a function signature states a conditional universal claim — “for all types satisfying these bounds, this property holds.” But every conditional claim demands evidence. The bound T: Ord in a function signature is a hypothesis, and hypotheses must be discharged. Something must supply the proof that a particular type actually satisfies a particular trait.

That something is the impl block. And the space of all impl blocks — their relationships, their derivation rules, their constraints — constitutes a domain in its own right. This is Level 3: the proof domain.

At Levels 1 and 2, the programmer works with types and the propositions they satisfy. At Level 3, the focus shifts to the proofs themselves — the evidence that connects concrete types to abstract propositions. This shift in perspective reveals structure that is invisible from the lower levels: impl blocks form a category with its own objects, morphisms, and coherence conditions. Blanket impls are not mere convenience — they are proof constructors, systematic derivations that produce new proofs from existing ones. The orphan rule is not an arbitrary restriction — it is the condition that keeps the proof system consistent.

Chapter 2 established that impl blocks are proof terms. This chapter examines the structure of the proof domain as a mathematical object.

Impl Blocks as Proof Objects

An impl block is a piece of evidence. When you write:

#![allow(unused)]
fn main() {
use std::fmt;

struct Metres(f64);

impl fmt::Display for Metres {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}m", self.0)
    }
}
}

you are constructing a proof of the proposition Display(Metres). The proof consists of a concrete implementation of every method the trait requires — here, just fmt. The compiler verifies the proof by checking that the method signatures match and the body type-checks. If the proof is valid, the proposition is established: Metres satisfies Display.

This is a ground proof — it concerns one specific type and one specific trait. It is a fact, not a derivation. It does not depend on other proofs or on any conditions. The proposition Display(Metres) is simply true, established by the evidence in the impl block.

Let us now consider the collection of all such proof objects for a given trait.

The Category Impl(Trait)

For any trait Trait, we can define a category Impl(Trait) whose objects are the impl blocks witnessing that various types satisfy Trait.

Take Display as our example. The objects of Impl(Display) include:

  • impl Display for i32 (provided by the standard library)
  • impl Display for String (provided by the standard library)
  • impl Display for bool (provided by the standard library)
  • impl Display for Metres (our proof above)
  • Every other impl Display for T in the program

Each object in this category is a proof — a concrete piece of evidence that a specific type satisfies the Display proposition. The category Impl(Display) is the collection of all known proofs of Display-ness.

But a category needs more than objects. It needs morphisms — arrows between objects that express relationships. What are the morphisms in Impl(Trait)?

Blanket Impls as Morphisms

The morphisms in Impl(Trait) are blanket impls: impl blocks that systematically derive new proofs from existing ones.

Consider the standard library’s blanket impl:

impl<T: Display> ToString for T {
    fn to_string(&self) -> String {
        // uses Display::fmt internally
        format!("{self}")
    }
}

This is not a ground proof. It is a proof constructor: given any proof that T: Display, it produces a proof that T: ToString. It is a morphism in the proof domain — an arrow from Impl(Display) to Impl(ToString).

In categorical terms, this blanket impl is a functor between proof categories: it maps objects of Impl(Display) to objects of Impl(ToString), systematically and uniformly. For every type T that has a Display proof, the blanket impl automatically generates a ToString proof. No manual construction is needed — the derivation is mechanical.

    Impl(Display)                    Impl(ToString)
    ┌────────────────┐               ┌────────────────┐
    │ Display for i32 │──────────────→│ ToString for i32│
    │ Display for String│────────────→│ ToString for String│
    │ Display for bool │─────────────→│ ToString for bool│
    │ Display for Metres│────────────→│ ToString for Metres│
    └────────────────┘               └────────────────┘
              blanket impl: ∀T: Display. ToString for T

Each arrow is an instance of the blanket impl, applied to a specific type. The blanket impl is the rule; the arrows are its applications. The functor maps the entire category at once.

Multi-Premise Derivations

Some blanket impls derive a proof from multiple existing proofs:

#![allow(unused)]
fn main() {
trait Summarise {
    fn summarise(&self) -> String;
}

impl<T: std::fmt::Display + Clone> Summarise for Vec<T> {
    fn summarise(&self) -> String {
        let items: Vec<String> = self.iter().map(|x| x.to_string()).collect();
        format!("[{}]", items.join(", "))
    }
}
}

This derivation says: for all types T, if T satisfies Display and Clone, then Vec<T> satisfies Summarise. The proof draws from two premises (Display and Clone) and produces a conclusion for a different type (Vec<T> rather than T itself). In categorical terms, this is a functor from the product category Impl(Display) × Impl(Clone) into Impl(Summarise), composed with the Vec type constructor.

Iterated Derivation

Blanket impls can chain. Consider how the standard library derives proofs for compound types:

// If T: Clone, then Vec<T>: Clone
impl<T: Clone> Clone for Vec<T> { ... }

// If T: Clone, then Option<T>: Clone
impl<T: Clone> Clone for Option<T> { ... }

Starting from a ground proof impl Clone for i32, the first blanket impl derives impl Clone for Vec<i32>. Applying the second derives impl Clone for Option<Vec<i32>>. Applying the first again derives impl Clone for Vec<Option<Vec<i32>>>. The derivation can iterate indefinitely, producing proofs for arbitrarily nested types from a single ground proof.

This iterated derivation has the structure of a free construction: given a set of ground proofs (the generators) and a set of derivation rules (the blanket impls), the proof domain is the closure of the generators under the rules. Every proof in the domain is either a ground proof or the result of finitely many rule applications.

The standard library’s trait implementations are designed with this structure in mind. The ground proofs cover the primitive types (i32, bool, char, etc.), and the blanket impls propagate traits through the standard type constructors (Vec, Option, Box, Result, tuples). The result is that most compound types — Vec<Option<String>>, (i32, bool, Vec<u8>) — automatically satisfy the common traits, without any manual impl blocks. The proof domain generates them.

The Orphan Rule as Coherence

The orphan rule is Rust’s most distinctive constraint on the proof domain. It states: you may write impl Trait for Type only if you define either Trait or Type (or both) in the current crate. You cannot implement a foreign trait for a foreign type.

// In your crate — allowed, because you own Metres
impl Display for Metres { ... }

// In your crate — FORBIDDEN, because you own neither Display nor Vec
impl Display for Vec<i32> { ... }

Under the standard framing, this is presented as an arbitrary restriction or a practical necessity to avoid conflicts. Under the proof-domain framing, it is something more precise: the orphan rule is a coherence condition on the proof category.

What Coherence Means

Coherence requires that for any type T and any trait Trait, there is at most one proof of Trait(T) in the entire program. No ambiguity. No conflicting evidence. If you ask “does i32 satisfy Display?”, there is exactly one answer, backed by exactly one proof.

Why does this matter? Consider what would happen without coherence. Suppose two crates could each provide impl Ord for SomeType, with different comparison functions. A generic function bounded by T: Ord would behave differently depending on which impl was selected. The same call to sort could produce different orderings. The program’s behaviour would depend on the choice of proof — and in a system with proof erasure, that choice is invisible at runtime.

This is precisely the situation that Reynolds’ representation independence theorem addresses. Reynolds showed in 1978 that if two implementations of an abstract type are related by a suitable simulation, no program can distinguish them. The formal justification for coherence is the converse requirement: since Rust erases proofs at the boundary, and erased proofs cannot be distinguished at runtime, the language must ensure that no observable behaviour depends on which proof is selected. The simplest way to ensure this is to require that there is only one proof — which is exactly what the orphan rule enforces.

Coherence is not about preventing bugs. It is about maintaining the soundness of proof erasure. If proofs are going to be erased, they must be unique — otherwise the erasure would discard information that matters.

The Orphan Rule in Detail

The rule is more nuanced than “own the trait or the type.” The precise condition involves the notion of a local type — a type defined in the current crate — and applies recursively through type parameters. The key cases:

#![allow(unused)]
fn main() {
use std::fmt;
struct Metres(f64);
// You own Metres → you can implement foreign traits for it
impl fmt::Display for Metres {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}m", self.0)
    }
}
}
#![allow(unused)]
fn main() {
// You own the trait → you can implement it for foreign types
trait Measurable {
    fn magnitude(&self) -> f64;
}

impl Measurable for f64 {
    fn magnitude(&self) -> f64 {
        self.abs()
    }
}
}
// You own neither Display nor Vec<i32> → FORBIDDEN
impl Display for Vec<i32> { ... }

The orphan rule draws a boundary around each crate’s proof authority. Each crate can produce proofs involving its own types and traits, but cannot produce proofs that are entirely about foreign entities. This partitioning ensures that proofs cannot conflict: two crates cannot both provide impl Trait for Type unless at least one of them owns Trait or Type, in which case the other’s attempt would be rejected by the rule.

Coherence and the Category

In categorical terms, coherence means that Impl(Trait) is a thin category: between any two objects, there is at most one morphism. More precisely, for any type T, either there exists exactly one proof of Trait(T) (via a ground impl or a unique blanket derivation chain), or there is no proof. There is never a choice between proofs.

This thinness is what makes proof erasure safe. If the category were thick — if multiple proofs could coexist for the same type and trait — then erasing the proof would lose information about which proof was intended. In a thin category, there is nothing to lose. The proof’s existence matters; its identity does not.

Overlap and Specialisation

The thin category property depends on the compiler rejecting overlapping impls — two blanket impls that could both apply to the same type. Consider:

trait Describe {
    fn describe(&self) -> String;
}

// These two impls overlap: a type implementing both Debug and Display
// would have two candidate proofs of Describe.
impl<T: std::fmt::Debug> Describe for T {
    fn describe(&self) -> String { format!("{:?}", self) }
}

impl<T: std::fmt::Display> Describe for T {   // ERROR: conflicting impl
    fn describe(&self) -> String { format!("{}", self) }
}

Stable Rust forbids this entirely. If two blanket impls could apply to any overlapping set of types, the proof category would no longer be thin — some types would have two proofs of the same proposition, and the compiler would have no principled way to choose between them. The overlap ban is the constructive mechanism that enforces coherence.

Specialisation (available only on nightly, via #![feature(specialization)] or #![feature(min_specialization)]) relaxes this rule in a controlled way. It permits overlapping impls when one is strictly more specific than the other — the more specific impl “wins” for the types it covers:

#![feature(min_specialization)]

trait Describe {
    fn describe(&self) -> String;
}

// General blanket impl — applies to all types with Debug.
impl<T: std::fmt::Debug> Describe for T {
    default fn describe(&self) -> String { format!("{:?}", self) }
}

// Specialised impl — applies to types with Display (a stricter condition
// in practice, since Display implies a more curated representation).
// Where both apply, this one wins.
impl<T: std::fmt::Display> Describe for T {
    fn describe(&self) -> String { format!("{}", self) }
}

From the categorical perspective, specialisation relaxes the thin category to a preorder-enriched category. Multiple candidate proofs may exist for a single type, but a deterministic selection rule — “choose the most specific” — ensures a unique winner. The category is no longer thin (multiple morphisms exist), but a priority ordering on morphisms recovers uniqueness of selection.

The tension is real. Specialisation enables useful patterns — the standard library would benefit from impl<T: Clone> ToOwned for T with a special case for str that avoids unnecessary allocation. But it weakens the absolute coherence guarantee that makes proof erasure straightforward. Specialisation remains on nightly precisely because the proof-theoretic consequences — particularly around soundness when default associated types interact with lifetime bounds — are still being resolved. The decision to stabilise it is, in effect, a question about how much thinness the proof category can afford to lose.

Newtype Wrappers: Working Within the Orphan Rule

The orphan rule occasionally prevents you from providing a proof you need. You want impl SomeForeignTrait for SomeForeignType, but you own neither. The standard solution is the newtype wrapper: a new type that you do own, wrapping the foreign type.

#![allow(unused)]
fn main() {
use std::fmt;

struct FormattedList(Vec<String>);

impl fmt::Display for FormattedList {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}
}

FormattedList is a new type — you own it — that wraps Vec<String>. You can implement any trait for FormattedList because you own it. The newtype is a one-line definition: struct FormattedList(Vec<String>).

In the proof domain, the newtype is a relabelling of the proof target. You cannot prove Display(Vec<String>) directly (orphan rule), so you create a new node in the type lattice — FormattedList — that is representationally identical to Vec<String> but logically distinct. Then you prove Display(FormattedList), which the orphan rule permits.

The cost is that you must convert between Vec<String> and FormattedList at the point of use. The forgetful functor erases the distinction — both are identical in 𝒱 — so the conversion is zero-cost. The newtype exists purely in 𝒯 as a device for navigating the orphan rule.

#![allow(unused)]
fn main() {
use std::fmt;
struct FormattedList(Vec<String>);
impl fmt::Display for FormattedList {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}
fn display_items(items: Vec<String>) {
    let formatted = FormattedList(items);
    println!("{formatted}");
}
}

The newtype pattern is the primary tool for extending the proof domain when the orphan rule blocks direct proof construction. It is used so frequently in Rust that it has become idiomatic — a standard technique that every experienced Rust programmer recognises.

Newtypes and Proof Variation

Newtypes also serve a second purpose in the proof domain: providing alternative proofs for the same proposition, in a system that allows only one proof per type.

Consider ordering. f64 has a partial ordering (some values like NaN are incomparable), but sometimes you want a total ordering that handles NaN in a specific way. You cannot write a second impl Ord for f64 — coherence forbids it. But you can define a newtype:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq)]
struct TotalF64(f64);

impl Eq for TotalF64 {}

impl PartialOrd for TotalF64 {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for TotalF64 {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.total_cmp(&other.0)
    }
}
}

TotalF64 is a new type with a new proof of Ord — a proof that uses total_cmp to impose a total ordering on all f64 values including NaN. The original f64 retains its partial ordering; TotalF64 has a total ordering. Two different proofs of ordering-ness, coexisting without conflict, because they apply to different types.

In the proof domain, the newtype is a mechanism for proliferating proof targets. If you need multiple proofs of the same proposition but coherence requires uniqueness, you create distinct types (distinct nodes in the lattice) and attach distinct proofs to each. The representational identity is preserved by the forgetful functor — in 𝒱, they are the same bytes — but the logical identity in 𝒯 is distinct, and distinct identities can carry distinct proofs.

Representation Independence

John Reynolds’ representation independence theorem, introduced in Chapter 1, provides the formal foundation for coherence. The theorem states: if two implementations of an abstract type are related by a suitable simulation, then no program can distinguish them.

In the proof domain, this means: if two impl blocks for the same trait and type are “equivalent” (they produce observationally identical behaviour), then replacing one with the other does not change any program’s behaviour. Representation independence tells us that the choice of proof witness is unobservable — as long as the proofs agree on their observable behaviour.

Rust takes this one step further. Rather than requiring proofs to agree (which would be difficult to verify), Rust simply prohibits the situation from arising: the orphan rule ensures there is only one proof, so the question of agreement never arises. This is coherence by uniqueness rather than coherence by agreement.

The distinction matters when comparing with other languages:

Haskell has a similar coherence requirement for type class instances, but it is weaker in practice: orphan instances (instances defined in a module that owns neither the class nor the type) are permitted with a warning. This can lead to incoherence — different modules seeing different instances of the same class for the same type. The result is subtle bugs where the behaviour of a function depends on which module’s instance is in scope.

Scala with its given (formerly implicit) mechanism permits multiple proof witnesses in scope, with an explicit resolution order. This is coherence by priority rather than by uniqueness — the language chooses the “best” proof according to a set of rules, but the programmer must understand the rules to predict which proof is selected.

Rust is the most conservative: one proof, no exceptions, no resolution needed. The cost is the orphan rule’s restrictions. The benefit is absolute predictability — the proof selected for T: Trait is never ambiguous.

Proof Erasure Revisited

Chapter 2 established that Rust erases proofs at the boundary. At Level 3, we can examine precisely what this erasure means for the proof domain.

Consider a function that uses a trait bound:

#![allow(unused)]
fn main() {
fn sort_and_first<T: Ord>(mut items: Vec<T>) -> Option<T> {
    items.sort();
    items.into_iter().next()
}
}

In 𝒯, this function requires a proof of Ord(T) — an impl block. The proof provides the comparison function that sort uses. At the call site, the compiler resolves the proof:

fn sort_and_first<T: Ord>(mut items: Vec<T>) -> Option<T> {
    items.sort();
    items.into_iter().next()
}
fn main() {
    let result = sort_and_first(vec![3, 1, 2]);
    // The compiler resolves: impl Ord for i32
    // and monomorphises sort_and_first::<i32>
    assert_eq!(result, Some(1));
}

When the boundary is crossed (monomorphisation), the proof is consumed:

  1. The compiler finds impl Ord for i32.
  2. It extracts the comparison function from the impl block.
  3. It generates sort_and_first::<i32> with the comparison function inlined.
  4. The impl block itself — the proof object — is discarded. In the generated code, there is no trace of the proof. There is only the comparison function, baked into the sorted function’s machine code.

The proof’s content (the comparison function) survives the boundary. The proof’s identity (the fact that it was an impl of Ord for i32) does not. This is the forgetful functor applied to the proof domain: F preserves computational content while erasing logical structure.

What Survives and What Does Not

Proof componentAbove the boundary (𝒯)Below the boundary (𝒱)
Method implementationsAbstract trait methodsInlined concrete code
Proof identity (which impl)Known, used for coherence checkingErased
Supertrait chainVerified, used for bound checkingErased
Associated type bindingsKnown, used for type resolutionResolved to concrete types
The impl block as an objectExists as a proof termDoes not exist

The erasure is thorough. In the generated machine code, there is no record that sort_and_first::<i32> was ever generic, that it ever required Ord, or that a proof was ever consulted. The code is identical to what you would write by hand at Level 0.

First-Class Proofs: What Other Languages Do

Rust erases proofs. Not all languages make this choice. Understanding the alternatives illuminates what Rust gives up and what it gains.

Haskell: Dictionary Passing

Haskell’s type classes — the closest analogue to Rust’s traits — are implemented via dictionary passing. When a Haskell function has a type class constraint, the compiler translates it into a function that takes an extra argument: the dictionary, a record of the class’s methods for the specific type.

In pseudocode, the Haskell equivalent of:

-- Haskell
sort :: Ord a => [a] -> [a]

is compiled to something like:

-- After dictionary translation
sort :: OrdDict a -> [a] -> [a]

where OrdDict a is a record containing the comparison function (and the superclass dictionaries for Eq). The dictionary is passed at runtime, as an ordinary function argument. The proof survives the boundary as a data structure.

This gives Haskell capabilities that Rust lacks. A Haskell program can:

  • Store a dictionary in a data structure (store a proof for later use)
  • Pass a dictionary explicitly to override the default instance
  • Compute with dictionaries at runtime

The cost is runtime overhead: every polymorphic function call carries an extra pointer (or several, for compound constraints). Dictionary passing is why Haskell’s polymorphic code is typically slower than monomorphised code — the proof has a runtime representation, and that representation has a cost.

Scala: Given Instances

Scala’s given mechanism (formerly implicit) takes a middle path. Proof witnesses (called given instances) are values that the compiler passes automatically, but the programmer can also pass them explicitly:

// Scala
given Ordering[Metres] with
  def compare(a: Metres, b: Metres): Int = ...

// Explicit passing
def sortWith[T](items: List[T])(using ord: Ordering[T]): List[T] = ...

The using keyword makes the proof argument visible in the signature. A caller can supply an alternative proof explicitly, overriding the default resolution. Proofs are first-class values — they can be stored, passed, and computed with — but the compiler provides them automatically when an unambiguous resolution exists.

This is more flexible than Rust’s approach. It allows multiple orderings for the same type, selected by the caller:

// Scala
sortWith(items)(using Ordering.reverse)  // sort in reverse order
sortWith(items)(using myCustomOrdering)   // sort with a custom ordering

Rust cannot express this directly. The proof of Ord for T is always the unique one selected by coherence. To get alternative orderings, you must use the newtype pattern — creating a new type with a different proof, rather than selecting among proofs for the same type.

The Trade-Off

The three approaches occupy a spectrum:

ApproachProofs at runtimeMultiple proofsOverhead
RustErasedForbidden (coherence)Zero
HaskellDictionariesForbidden (coherence, in principle)Pointer per constraint
ScalaGiven valuesPermitted (explicit selection)Depends on usage
Coq/IdrisFirst-class valuesPermitted (fully dependent)Full value cost

Rust sits at the far end of the spectrum: maximum erasure, maximum coherence, zero overhead. The price is inflexibility — you cannot select among proofs or manipulate proofs as values. The benefit is the zero-cost guarantee and the absolute predictability of proof resolution.

Unsafe: Axioms Without Proof

In the proof domain so far, every proof is verified by the compiler. The impl block is checked: method signatures must match, bodies must type-check, lifetime constraints must be satisfied. The proof system is sound — only true propositions can be proved.

unsafe introduces a different proof mechanism: the axiom. An axiom is a proposition the programmer asserts without the compiler verifying it.

Unsafe Traits and Unsafe Impls

An unsafe trait declares a proposition with proof obligations that go beyond what the compiler can check:

#![allow(unused)]
fn main() {
/// # Safety
/// Implementors must ensure that the type can be safely
/// transferred across thread boundaries.
unsafe trait ThreadSafe {
    fn check(&self) -> bool;
}
}

An unsafe impl provides an unverified proof — the programmer claims the obligations are met, but the compiler trusts without checking:

#![allow(unused)]
fn main() {
unsafe trait ThreadSafe { fn check(&self) -> bool; }
struct MyBuffer {
    data: Vec<u8>,
}

// The programmer asserts — without compiler verification — that
// MyBuffer is safe to transfer across threads.
unsafe impl ThreadSafe for MyBuffer {
    fn check(&self) -> bool { true }
}
}

Under Curry-Howard, an unsafe impl is an axiom added to the proof system. It extends the system’s reach — propositions that the compiler cannot verify can now be asserted — but it introduces a soundness risk. If the axiom is false (the type is not actually safe to transfer across threads), the entire proof system becomes unsound. False axioms are the source of undefined behaviour.

The standard library’s most important unsafe traits are Send and Sync. Their proof obligations — “this type can be sent to another thread” and “this type can be shared between threads” — are semantic properties that depend on runtime behaviour the compiler cannot fully analyse. The unsafe marker signals that the proof obligation exists, and the unsafe impl signals that the programmer takes responsibility for discharging it.

Unsafe Blocks: Suspended Verification

unsafe blocks serve a different but related purpose. An unsafe block is not an unverified proof — it is a region where the compiler suspends certain verification. Inside an unsafe block, the programmer can perform operations that the compiler cannot prove safe: dereferencing raw pointers, calling unsafe functions, accessing mutable statics.

#![allow(unused)]
fn main() {
fn read_at(data: &[u8], index: usize) -> u8 {
    assert!(index < data.len());
    // SAFETY: bounds check above guarantees index is valid
    unsafe { *data.as_ptr().add(index) }
}
}

The proof obligations — pointer validity, no aliasing violations, proper initialisation — are not discharged by the compiler. They are discharged by the programmer’s reasoning, expressed in the // SAFETY comment. The compiler trusts the programmer within the block’s scope.

The Boundary Perspective

Unsafe code does not change F — the forgetful functor erases unsafe proofs exactly as it erases safe ones. Below the boundary, there is no distinction between a function whose Send impl was compiler-verified and one whose Send impl was asserted by the programmer. The difference is entirely in 𝒯: safe proofs are verified, unsafe proofs are trusted. The generated machine code is identical.

This means that an incorrect unsafe impl is undetectable below the boundary. The unsoundness manifests not as a type error but as undefined behaviour — a violation of the proof system’s assumptions that the runtime cannot catch. This is why unsafe is Rust’s sharpest tool: it extends the proof system’s power at the cost of transferring the burden of correctness from the compiler to the programmer.

The Proof Domain in Practice

Let us trace a realistic example through the proof domain, identifying each proof and its role.

#![allow(unused)]
fn main() {
use std::fmt;

trait Unit: fmt::Display + Copy {
    fn abbreviation() -> &'static str;
}

#[derive(Clone, Copy)]
struct Kg;

impl fmt::Display for Kg {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "kg")
    }
}

impl Unit for Kg {
    fn abbreviation() -> &'static str { "kg" }
}

#[derive(Clone, Copy)]
struct Lb;

impl fmt::Display for Lb {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "lb")
    }
}

impl Unit for Lb {
    fn abbreviation() -> &'static str { "lb" }
}

struct Measurement<U: Unit> {
    value: f64,
    _unit: std::marker::PhantomData<U>,
}

impl<U: Unit> Measurement<U> {
    fn new(value: f64) -> Self {
        Measurement { value, _unit: std::marker::PhantomData }
    }
}

impl<U: Unit> fmt::Display for Measurement<U> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2} {}", self.value, U::abbreviation())
    }
}
}

The proof domain for this code contains:

Ground proofs:

  • impl Display for Kg — Kg can be displayed
  • impl Unit for Kg — Kg is a unit (which implies Display + Copy)
  • impl Display for Lb — Lb can be displayed
  • impl Unit for Lb — Lb is a unit
  • impl Copy for Kg — Kg can be copied (via derive)
  • impl Copy for Lb — Lb can be copied (via derive)

Derived proofs (blanket impls):

  • impl<U: Unit> Display for Measurement<U> — a blanket impl that derives Display for any measurement whose unit satisfies Unit. This is a morphism in the proof domain: it maps each proof in Impl(Unit) to a proof in Impl(Display) for the corresponding Measurement type.

Derivation chain for Measurement<Kg>:

  1. impl Unit for Kg (ground proof)
  2. impl Display for Measurement<Kg> (derived from step 1 via the blanket impl)

The blanket impl is the engine of the proof domain. It says: I do not need to prove Display for every concrete measurement type. I prove it once, generically, and the proof domain generates the specific instances. This is the power of Level 3: proof construction, not just proof assertion.

The Structure of Derivation

The proof domain’s derivation structure has several properties worth noting.

Proof Derivations Are Unique

Because of coherence, the derivation chain that produces a proof is unique. There is exactly one path from the ground proofs to impl Display for Measurement<Kg>: the blanket impl applied to impl Unit for Kg. No alternative derivation exists — if it did, it would create a second proof of Display(Measurement<Kg>), violating coherence.

This uniqueness is what makes the proof domain a thin category — at most one morphism between any two objects. In richer proof systems (Coq, for instance), multiple derivations of the same proposition can coexist, and the choice between them can affect the program. In Rust, the choice never arises.

Derive Macros as Proof Generators

Rust’s derive attribute is a mechanism for automatically generating ground proofs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Point {
    x: i32,
    y: i32,
}
}

Each trait in the derive list generates an impl block — a ground proof. derive(Debug) produces impl Debug for Point by inspecting the struct’s fields and generating formatting code. derive(Clone) produces impl Clone for Point by cloning each field.

In the proof domain, derive is a proof generator: given a type definition, it mechanically constructs proofs of standard propositions. The proofs it generates are ground proofs (not blanket impls), but they follow a uniform pattern determined by the struct’s structure.

The derive mechanism is Level 3 automation. Rather than writing each proof by hand, the programmer declares which propositions should hold, and the compiler generates the evidence. This is analogous to proof tactics in a theorem prover — automated strategies for constructing proofs of standard forms.

Conditional Derivation: Where Blanket Impls and Derive Interact

The derived impls often carry implicit conditions. derive(Clone) on a generic struct generates:

// What derive(Clone) generates for a generic struct:
impl<T: Clone> Clone for Wrapper<T> {
    fn clone(&self) -> Self {
        Wrapper(self.0.clone())
    }
}

This is a conditional proof: Clone(Wrapper<T>) holds if Clone(T) holds. It is a blanket impl generated by the derive macro. The condition T: Clone propagates the requirement downward — to clone the wrapper, you must be able to clone the contents.

This interaction between derive and blanket impls is the proof domain’s everyday mechanism. When you write #[derive(Clone)] on a generic struct, you are not just requesting a proof — you are requesting a proof schema that derives Clone for each instantiation of the struct, conditional on the type parameter satisfying Clone.

Auto Traits: Compiler-Generated Proofs

derive requires the programmer to opt in — you must write #[derive(Clone)] to request the proof. Auto traits go further: the compiler generates proofs automatically, without any annotation.

Send and Sync are the primary auto traits. The compiler proves Send for a type by structural induction: if every field of a struct is Send, the struct itself is Send. No derive annotation is needed; no impl block is written. The proof is generated silently, based on the type’s structure.

#![allow(unused)]
fn main() {
struct Pair {
    name: String,    // String: Send ✓
    count: u64,      // u64: Send ✓
}
// Pair: Send — proved automatically by the compiler.
// No annotation required.
}

This is proof by structural recursion — a well-known technique in type theory, here implemented by the compiler as an automatic derivation rule. The base cases are the primitive types (i32, bool, u8, etc., are all Send). The inductive step is: if all fields are Send, the composite is Send. The compiler applies this rule to every type in the program, building proofs bottom-up from the primitives.

Negative Impls: Refutation

Auto traits are negative by default for certain types. Rc<T> is not Send because its reference count is not atomic — sharing an Rc across threads would cause data races. Cell<T> is not Sync because interior mutability without synchronisation is unsound.

Rust expresses this through negative impls — the only place in the language where a proposition is explicitly denied:

// In the standard library (using an unstable feature):
impl<T: ?Sized> !Send for Rc<T> {}
impl<T: ?Sized> !Sync for Cell<T> {}

Under Curry-Howard, a negative impl is a refutation: an assertion that the proposition is false. Where a positive impl says “here is evidence that T satisfies Trait,” a negative impl says “no such evidence can exist.” The proposition Send(Rc<T>) is not merely unproved — it is actively denied.

This denial propagates structurally. If a struct contains an Rc<T> field, the compiler’s structural induction encounters a field that is not Send, and the automatic proof fails. The struct inherits the negative property: it is not Send either, unless the programmer provides an unsafe impl Send — an axiom overriding the compiler’s analysis.

The Default-With-Override System

Together, auto traits and negative impls form a default-with-override proof system:

  1. Auto-derivation generates Send/Sync proofs for types whose structure permits it — the default.
  2. Negative impls deny the proof for types whose semantics forbid it — the override downward.
  3. unsafe impl asserts the proof for types whose safety the compiler cannot verify — the override upward, at the programmer’s risk.

This system extends the proof domain beyond what derive can express. derive generates proofs the programmer requests; auto traits generate proofs the programmer did not request, based on structural analysis. The connection to unsafe is direct: Send and Sync are unsafe trait — implementing them manually is an axiom (as discussed in the previous section), while the automatic derivation is safe because it follows structural rules the compiler can verify.

The Boundary at Level 3

What happens to the proof domain when the forgetful functor F is applied?

The entire domain — every impl block, every derivation chain, every coherence relationship — is erased. In 𝒱, there are no proofs. There are only the concrete functions that the proofs licensed.

For a monomorphised function:

sort_and_first::<i32>

The proof impl Ord for i32 has been consumed. The comparison function has been extracted and inlined. The impl block itself — the proof that i32 is orderable — has no runtime representation. The question “is i32 orderable?” cannot even be asked at runtime, because the proof machinery does not exist in 𝒱.

For a dyn Trait object, the story is different. A vtable is a partial projection of a proof into 𝒱. The proof’s methods survive as function pointers, but the proof’s identity (which type, which impl) is erased. The vtable preserves computational content — you can call the methods — but not logical content — you cannot ask which type the proof was about.

This dual treatment — full erasure for monomorphic dispatch, partial projection for dynamic dispatch — is characteristic of Rust’s boundary. The proof domain exists entirely in 𝒯. The boundary either erases it completely (static dispatch, zero cost) or projects it partially (dynamic dispatch, vtable cost). Either way, the proof as a logical object does not survive.

What Level 3 Reveals

The Level 2 programmer sees traits as interfaces and bounds as requirements. The Level 3 programmer sees something different: a deductive system.

The impl blocks are axioms and derivation rules. The blanket impls are inference rules — “if these propositions hold, then this proposition also holds.” The orphan rule is the consistency constraint. The derive macro is an automated prover. And the entire system is verified at compile time and erased at the boundary.

This perspective changes how you think about API design:

Trait definitions are not just interfaces — they are propositions you are introducing into the logic. When you define a trait, you create a new predicate that types can satisfy. Every impl block for that trait is a proof. Every blanket impl involving that trait is an inference rule. Choose your propositions carefully: each one extends the deductive system, and the coherence constraint means the extension is permanent within a crate’s scope.

The orphan rule is not a restriction — it is a partition of proof authority. Each crate has a jurisdiction: the types and traits it defines. Within that jurisdiction, it has full authority to construct proofs. The orphan rule prevents jurisdictions from conflicting. The newtype pattern is the standard way to extend your jurisdiction when needed.

Blanket impls are the most powerful tool at Level 3. A single blanket impl can generate proofs for an unbounded number of types. impl<T: Display> ToString for T covers every displayable type, present and future. This is proof by universal derivation — a single rule that applies wherever its premises hold.

The proof domain is where Rust’s type system achieves its most distinctively logical character. At Level 2, types express propositions. At Level 3, the compiler manages a deductive system — constructing, verifying, and erasing proofs in a formally coherent framework. The programmer participates in this system every time they write an impl block, whether they think of it in these terms or not.

The next chapter ascends to Level 4, where types themselves become the objects of computation — type constructors, GATs, and the typestate pattern. The proofs of Level 3 will remain essential, but the propositions they prove will grow more abstract.

Type Constructors and GATs (Level 4)

The previous levels dealt with types as parameters to functions. A generic function takes a type and produces a value. A bounded generic function takes a type satisfying a predicate and produces a value. But in all of these, the function is a value-level entity — something that exists (after monomorphisation) in 𝒱. The type parameter is an input to that value-level function, resolved at compile time and erased at the boundary.

Level 4 changes the subject. Here, the functions are not value-level functions parameterised by types. They are type-level functions — functions that take types and produce types, operating entirely within 𝒯. Vec is not a type. It is a function that takes a type T and returns the type Vec<T>. Option takes a type and returns a type. Result takes two types and returns a type. These are type constructors: the second axis of the lambda cube, types depending on types.

This is the highest level that Rust reaches. At Level 4, the programmer works not with values or with propositions about values, but with the type-level machinery itself — constructing types from types, parameterising associated types by lifetimes, encoding state machines in phantom parameters that exist purely above the boundary with zero runtime cost. The boundary erases everything at this level. The proofs, the types, the state transitions — all verified, all discarded. What remains in 𝒱 is bare computation, with the guarantee that the type-level reasoning was sound.

Type Constructors as Functions in 𝒯

Chapter 1 introduced the second axis of the lambda cube: types depending on types. In Rust, this axis is inhabited by every generic type definition.

#![allow(unused)]
fn main() {
struct Wrapper<T> {
    inner: T,
}
}

Wrapper is not a type. It is a type-level function — a mapping from the space of types to the space of types:

Wrapper: Type → Type

Wrapper(i32)    = Wrapper<i32>
Wrapper(String) = Wrapper<String>
Wrapper(bool)   = Wrapper<bool>

The input is a type. The output is a type. The function operates entirely within 𝒯 — no values are involved in the mapping itself. Only when a specific fibre like Wrapper<i32> is inhabited by a value does the mapping touch 𝒱.

Multi-parameter type constructors are functions of multiple arguments:

Result: Type × Type → Type

Result(i32, String)    = Result<i32, String>
Result((), ParseError) = Result<(), ParseError>

And type constructors compose. Vec<Option<T>> is the composition of two type-level functions: first apply Option to T, then apply Vec to the result. In functional notation: Vec ∘ Option, evaluated at T.

(Vec ∘ Option)(i32)    = Vec<Option<i32>>
(Vec ∘ Option)(String) = Vec<Option<String>>

This composition happens constantly in Rust — every nested generic type is a composition of type-level functions. But Rust does not let you name this composition abstractly. You can write Vec<Option<T>> for a specific T, but you cannot write a function that takes Vec ∘ Option as an argument — that would require higher-kinded types, which Rust does not have. We will return to this gap at the end of the chapter.

The Distinction from Lower Levels

At Level 1, fn identity<T>(x: T) -> T is a value-level function parameterised by a type. The type T is an input that determines which function you get, but the output is a value.

At Level 4, Vec<T> is a type-level function. The type T is an input, and the output is another type. No values are involved in the mapping itself.

The distinction is:

LevelDomainCodomainRust syntax
1–2TypesValuesfn f<T>(x: T) -> T
4TypesTypesstruct Vec<T> { ... }

Level 4 operates one stratum higher in the lambda cube. At Levels 1–2, the programmer writes functions that use types. At Level 4, the programmer writes definitions that produce types. The type constructor is a tool for building the type landscape itself.

Generic Associated Types

Associated types, introduced in Chapter 5, define a type-level function from the implementing type to another type: for each type C: Container, the associated type C::Element is determined by C. But ordinary associated types are fixed — they cannot themselves be parameterised.

Generic associated types (GATs) lift this restriction. A GAT is an associated type that takes its own parameters — type parameters or lifetime parameters — making it a type-level function within a type-level function.

Lending Iterators

The motivating example for GATs in Rust is the lending iterator: an iterator whose items borrow from the iterator itself, rather than from an external source.

The standard Iterator trait cannot express this:

// Standard Iterator — Item has no connection to the iterator's lifetime
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

The associated type Item is fixed for each implementing type. It cannot depend on the lifetime of the borrow in &mut self. A lending iterator needs Item to be parameterised by that lifetime — which requires a GAT:

#![allow(unused)]
fn main() {
trait LendingIterator {
    type Item<'a> where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>>;
}
}

The associated type Item<'a> is a type-level function from lifetimes to types. For each lifetime 'a, there is a corresponding Item<'a> type. The where Self: 'a bound ensures the iterator outlives the borrow.

#![allow(unused)]
fn main() {
trait LendingIterator {
    type Item<'a> where Self: 'a;
    fn next(&mut self) -> Option<Self::Item<'_>>;
}
struct Windows<'data, T> {
    data: &'data [T],
    pos: usize,
    size: usize,
}

impl<'data, T> LendingIterator for Windows<'data, T> {
    type Item<'a> = &'a [T] where Self: 'a;

    fn next(&mut self) -> Option<Self::Item<'_>> {
        if self.pos + self.size > self.data.len() {
            return None;
        }
        let window = &self.data[self.pos..self.pos + self.size];
        self.pos += 1;
        Some(window)
    }
}
}

The GAT Item<'a> = &'a [T] says: for each lifetime 'a, the item type is a slice reference with that lifetime. The lifetime of each yielded item is tied to the borrow of the iterator, not to some external data structure. This is a type-level function — lifetime → type — defined within the associated type.

Collection Families

GATs also allow expressing families of collections parameterised by their element type:

#![allow(unused)]
fn main() {
trait Collect {
    type Output<T>;
    fn collect_from<I: Iterator>(iter: I) -> Self::Output<I::Item>;
}

struct VecCollector;

impl Collect for VecCollector {
    type Output<T> = Vec<T>;
    fn collect_from<I: Iterator>(iter: I) -> Vec<I::Item> {
        iter.collect()
    }
}

struct OptionCollector;

impl Collect for OptionCollector {
    type Output<T> = Option<T>;
    fn collect_from<I: Iterator>(iter: I) -> Option<I::Item> {
        iter.last()
    }
}
}

The associated type Output<T> is a type-level function from T to a concrete collection type. For VecCollector, it maps every T to Vec<T>. For OptionCollector, it maps every T to Option<T>. The GAT captures the pattern of the type constructor — the shape of the container — as an abstract type-level function.

This is Level 4 in its purest form: a trait whose associated type is itself a type constructor.

GATs in the Lambda Cube

GATs are Rust’s closest approach to the second axis of the lambda cube in its full generality. An ordinary associated type is a fixed type — a point in 𝒯 determined by the implementing type. A GAT is a function in 𝒯 — a mapping from types (or lifetimes) to types, determined by the implementing type.

In categorical terms, a GAT is an indexed type-level function: for each implementing type, you get not a type but a type constructor. The Collect trait above maps each implementing type to a type-level function Type → Type. It is a function from types to type-level functions — a second-order construct.

Typestate: State Machines Above the Boundary

The typestate pattern is Level 4’s most distinctive practical application. It uses phantom type parameters to encode a state machine entirely within 𝒯, making invalid state transitions unrepresentable — not merely unchecked, but impossible to express in the type system.

The Pattern

Consider a network connection that must go through states: created → connected → authenticated → closed. At Level 0, you might model this with an enum:

#![allow(unused)]
fn main() {
#[allow(dead_code)]
enum ConnectionState {
    Created,
    Connected,
    Authenticated,
    Closed,
}
}

But an enum encodes which state the connection is in — it does not prevent invalid transitions. Nothing stops the programmer from going directly from Created to Authenticated, skipping Connected.

The typestate pattern encodes each state as a type, and the transitions as functions that consume one state and produce another:

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

struct Created;
struct Connected;
struct Authenticated;

struct Connection<State> {
    address: String,
    _state: PhantomData<State>,
}

impl Connection<Created> {
    fn new(address: &str) -> Self {
        Connection {
            address: address.to_string(),
            _state: PhantomData,
        }
    }

    fn connect(self) -> Connection<Connected> {
        // ... perform connection ...
        Connection {
            address: self.address,
            _state: PhantomData,
        }
    }
}

impl Connection<Connected> {
    fn authenticate(self, _token: &str) -> Connection<Authenticated> {
        // ... perform authentication ...
        Connection {
            address: self.address,
            _state: PhantomData,
        }
    }
}

impl Connection<Authenticated> {
    fn send(&self, _data: &[u8]) {
        // ... send data ...
    }

    fn close(self) {
        // ... close connection ...
        // Connection<Authenticated> is consumed; no further use possible.
    }
}
}

Each state is a separate type (Created, Connected, Authenticated). The Connection<State> struct uses PhantomData<State> to carry the state in 𝒯 without any runtime representation. Each impl block applies only to connections in a specific state. The transitions — connect, authenticate, close — consume self (taking ownership) and return a connection in the new state.

The critical property: invalid transitions do not type-check.

let conn = Connection::new("example.com");
// conn is Connection<Created>

conn.send(&[1, 2, 3]);
// ERROR: no method named `send` found for `Connection<Created>`
// send is only available on Connection<Authenticated>

conn.authenticate("token");
// ERROR: no method named `authenticate` found for `Connection<Created>`
// authenticate is only available on Connection<Connected>

The programmer cannot send data before authenticating, because send exists only on Connection<Authenticated>. The programmer cannot authenticate before connecting, because authenticate exists only on Connection<Connected>. The state machine is enforced at compile time.

Zero Cost

The typestate pattern has zero runtime cost. Let us trace what the forgetful functor does to this code.

PhantomData<State> is a zero-sized type. The compiler allocates no memory for it. In 𝒱, Connection<Created>, Connection<Connected>, and Connection<Authenticated> all have identical memory layouts: a String (the address). The state parameter exists only in 𝒯.

The transition function connect takes a Connection<Created> and returns a Connection<Connected>. In 𝒱, after monomorphisation, this is a function that takes a struct containing a String and returns a struct containing a String — the “state transition” is invisible. What was a type-level state change in 𝒯 is a no-op in 𝒱.

The verification — the guarantee that send is called only after authenticate, which is called only after connect — is performed entirely above the boundary and erased completely. This is zero-cost abstraction at its most dramatic: an entire state machine, with all its transition rules, verified at compile time and contributing exactly zero bytes and zero instructions to the runtime program.

Typestate versus Enum State

Compare the two approaches:

PropertyEnum state (Level 0)Typestate (Level 4)
Invalid transitionsPossible, caught at runtime (or not)Impossible, rejected at compile time
Runtime overheadTag byte, branch on stateZero — phantom types are ZSTs
State in 𝒯Partially (the enum type exists)Fully (each state is a distinct type)
State in 𝒱Yes (the tag discriminant)No (erased by F)
FlexibilityCan change state dynamicallyState path fixed at compile time

The typestate approach trades runtime flexibility for compile-time guarantee. An enum-based connection can transition dynamically (useful if the next state depends on runtime input). A typestate connection follows a fixed path — the sequence of states is determined by the code’s structure, verified by the compiler, and erased.

The choice between them is a design decision about where the state logic should live: in 𝒱 (enum, with runtime dispatch and dynamic transitions) or in 𝒯 (typestate, with static verification and zero cost).

The Boundary at Level 4

Level 4 has the most dramatic relationship with the boundary of any level. Everything at Level 4 — type constructors, GATs, phantom parameters, typestate machines — exists purely in 𝒯. The forgetful functor erases all of it.

Consider a function that exercises multiple levels:

#![allow(unused)]
fn main() {
use std::fmt;
use std::iter::FromIterator;

fn demonstrate<C, T>(items: &[T]) -> String
where
    C: FromIterator<T>,
    C: fmt::Debug,
    T: Clone,
{
    let collected: C = items.iter().cloned().collect();
    format!("{collected:?}")
}
}

This function lives simultaneously at multiple levels:

  • Level 1: It is parameterised by C and T (universally quantified).
  • Level 2: The bounds C: FromIterator<T> + Debug and T: Clone are predicates.
  • Level 3: Implicit proof witnesses (impl FromIterator<T> for C, impl Debug for C, impl Clone for T) must exist.
  • Level 4: C is being used as a type constructor — the caller specifies a container shape, and the function fills it.

When the boundary is crossed:

use std::fmt;
use std::iter::FromIterator;
fn demonstrate<C, T>(items: &[T]) -> String
where
    C: FromIterator<T>,
    C: fmt::Debug,
    T: Clone,
{
    let collected: C = items.iter().cloned().collect();
    format!("{collected:?}")
}
fn main() {
    let data = [1, 2, 3];
    let as_vec = demonstrate::<Vec<i32>, i32>(&data);
    let as_set = demonstrate::<std::collections::BTreeSet<i32>, i32>(&data);
    println!("{as_vec}");
    println!("{as_set}");
}

Monomorphisation generates two versions: one for Vec<i32> and one for BTreeSet<i32>. In each version:

  • The type parameters C and T are resolved to concrete types (Level 1 → Level 0).
  • The bounds are verified and erased (Level 2 → gone).
  • The proof witnesses are consumed and inlined (Level 3 → gone).
  • The type constructor C is resolved to a specific container (Level 4 → Level 0).

What remains in 𝒱 is two concrete functions — each collecting elements into a specific container and formatting the result. The entire generic machinery, from type constructors to proof witnesses, has been verified and discarded. The functions in 𝒱 are indistinguishable from hand-written Level 0 code.

Higher-Kinded Types: What Rust Cannot Express

Level 4 is where Rust reaches the edge of its expressive power. The limitation becomes visible when you try to abstract over type constructors themselves — when you want to write code that is generic not just in a type, but in a type-level function.

The Problem

Consider the map operation. Many types support it:

#![allow(unused)]
fn main() {
fn map_option<A, B>(opt: Option<A>, f: impl FnOnce(A) -> B) -> Option<B> {
    opt.map(f)
}

fn map_vec<A, B>(v: Vec<A>, f: impl FnMut(A) -> B) -> Vec<B> {
    v.into_iter().map(f).collect()
}

fn map_result<A, B, E>(
    res: Result<A, E>,
    f: impl FnOnce(A) -> B,
) -> Result<B, E> {
    res.map(f)
}
}

Three functions, all expressing the same idea: apply a function inside a container. The pattern is identical; only the container differs. At Level 2, we would abstract over the element type with a bound. But here we need to abstract over the container — and the container is a type constructor, not a type.

In Haskell, this abstraction is straightforward:

-- Haskell
class Functor f where
    fmap :: (a -> b) -> f a -> f b

The type variable f ranges over type constructors — things of kind * -> * (functions from types to types). f can be Maybe (Haskell’s Option), [] (Haskell’s Vec), Either e (Haskell’s Result<_, E>), and so on. The class abstracts over the container shape itself.

Rust cannot express this directly. The generic parameter in a trait bound must be a type, not a type constructor. You cannot write:

// Not valid Rust — illustrative pseudocode
trait Functor<F: * -> *> {
    fn fmap<A, B>(fa: F<A>, f: impl FnOnce(A) -> B) -> F<B>;
}

The parameter F would need to be a type-level function — a higher-kinded type. Rust’s type system does not have a way to quantify over type constructors. You can quantify over types (<T>), but not over type-level functions (<F: * -> *>).

GATs as a Partial Workaround

GATs provide a limited workaround. Instead of parameterising the trait by a type constructor, you can define a trait with a GAT that acts as a type constructor:

#![allow(unused)]
fn main() {
trait Mappable {
    type Item;
    type Output<U>;

    fn map_into<U>(self, f: impl FnMut(Self::Item) -> U) -> Self::Output<U>;
}

impl<T> Mappable for Option<T> {
    type Item = T;
    type Output<U> = Option<U>;

    fn map_into<U>(self, f: impl FnMut(T) -> U) -> Option<U> {
        self.map(f)
    }
}

impl<T> Mappable for Vec<T> {
    type Item = T;
    type Output<U> = Vec<U>;

    fn map_into<U>(self, mut f: impl FnMut(T) -> U) -> Vec<U> {
        self.into_iter().map(&mut f).collect()
    }
}
}

The GAT Output<U> acts as a type constructor: for Option<T>, it maps U to Option<U>. For Vec<T>, it maps U to Vec<U>. A generic function can now abstract over the container:

trait Mappable {
    type Item;
    type Output<U>;
    fn map_into<U>(self, f: impl FnMut(Self::Item) -> U) -> Self::Output<U>;
}
impl<T> Mappable for Option<T> {
    type Item = T;
    type Output<U> = Option<U>;
    fn map_into<U>(self, f: impl FnMut(T) -> U) -> Option<U> { self.map(f) }
}
impl<T> Mappable for Vec<T> {
    type Item = T;
    type Output<U> = Vec<U>;
    fn map_into<U>(self, mut f: impl FnMut(T) -> U) -> Vec<U> {
        self.into_iter().map(&mut f).collect()
    }
}
fn stringify<M: Mappable>(container: M) -> M::Output<String>
where
    M::Item: std::fmt::Display,
{
    container.map_into(|x| x.to_string())
}

fn main() {
    let nums = vec![1, 2, 3];
    let strs: Vec<String> = stringify(nums);
    assert_eq!(strs, vec!["1", "2", "3"]);

    let maybe = Some(42);
    let s: Option<String> = stringify(maybe);
    assert_eq!(s, Some("42".to_string()));
}

This works — stringify operates generically over any mappable container. But there are limitations. The GAT approach ties the type constructor to the implementing type, not to a standalone parameter. You cannot easily express “apply any type constructor F to any type T” — you can only express “this specific implementing type has an associated constructor.” The abstraction is one level less general than full higher-kinded types.

The Functor/Monad Gap

The inability to abstract over type constructors has a well-known consequence: Rust cannot define Functor, Applicative, or Monad as traits in the way Haskell does.

In Haskell, the monad abstraction is:

-- Haskell
class Monad m where
    return :: a -> m a
    (>>=)  :: m a -> (a -> m b) -> m b

The variable m ranges over type constructors. m can be Maybe, IO, Either e, [], or any other type constructor of kind * -> *. This single abstraction powers Haskell’s do notation, its effect handling, its entire approach to sequencing computations.

Rust has no equivalent. The ? operator works on Option and Result via compiler desugaring to pattern matching, not via a general monad abstraction. Iterator chains use a concrete Iterator trait, not a functor/applicative tower. Each “monad-like” type in Rust — Option, Result, Vec, Future — provides its own map, and_then, and flatten methods, with no shared trait.

This is not a deficiency in Rust’s design. It is a consequence of Rust’s position in the lambda cube. Full higher-kinded types live on the second axis in its full generality — the ability to quantify over type-level functions of arbitrary kind. Rust occupies this axis partially: it has type constructors (you can define Vec<T>), but it cannot quantify over them (you cannot write <F: * -> *>). GATs extend the reach, but the gap remains.

The practical impact is that Rust favours concrete abstraction over abstract abstraction. Rather than a single Monad trait that unifies all sequential computation, Rust has Option::and_then, Result::and_then, Future::then, each concrete, each zero-cost, each optimised for its specific use case. The type-level generality is sacrificed; the runtime efficiency is preserved.

What Level 4 Reveals

The Level 2 programmer thinks about types and their bounds. The Level 4 programmer thinks about type-level computation — functions that produce types, parameters that carry no runtime data, state machines that exist only in 𝒯.

This perspective reveals the final layer of Rust’s type-theoretical structure:

Type constructors are not types — they are functions. When you write Vec, you are not naming a type. You are naming a type-level function that, given a type, produces a type. The distinction matters because it determines what you can and cannot abstract over. You can abstract over T (the input); you cannot abstract over Vec (the function) without GATs or full HKT.

PhantomData is a zero-cost proof carrier. A phantom parameter adds structure in 𝒯 without cost in 𝒱. Typestate machines exploit this to encode invariants that are verified at compile time and erased at runtime. The boundary between 𝒯 and 𝒱 is what makes this possible: the phantom parameter is real structure in 𝒯, and the forgetful functor’s guarantee of erasure is what makes it zero-cost.

GATs extend the expressiveness of traits by one level. An ordinary associated type is a type (a point in 𝒯). A GAT is a type constructor (a function in 𝒯). This extra level of abstraction is enough for lending iterators, collection families, and other patterns that require the associated type to vary with an additional parameter. It is not enough for full higher-kinded types.

Rust’s boundary determines its position in the lambda cube. The zero-cost abstraction guarantee requires that all type-level structure be resolvable at compile time. Higher-kinded types, in their full generality, would require the compiler to reason about type-level functions as abstract entities — not just apply them to specific arguments. Rust’s monomorphisation strategy resolves every type-level function to a concrete result before crossing the boundary. This works for type constructors applied to specific types; it does not generalise to quantification over type constructors.

Level 4 is the ceiling. Above it lie the territories that Rust does not enter — dependent types, universe polymorphism, the full Calculus of Constructions. Chapter 8 examines the boundary itself: the forgetful functor F: 𝒯 → 𝒱 in its full depth, the mechanisms of erasure, and the selective permeability that allows certain information to cross between the two categories. Chapter 9 then maps the landscape beyond the ceiling, placing Rust precisely within the space of possible type systems.

The Boundary

The previous five chapters ascended through the type category 𝒯 level by level — from concrete types at Level 0, through parametric polymorphism at Level 1, constrained generics at Level 2, proof witnesses at Level 3, to type constructors and GATs at Level 4. At each level, the boundary made a brief appearance: monomorphisation collapsed generic families, proof witnesses were consumed and inlined, phantom types were erased to zero bytes. But the boundary itself was never the subject. It was background — a transformation that happened between the chapter’s type-level analysis and the generated machine code, acknowledged but not examined.

This chapter makes the boundary the subject — and in doing so, makes it a lens through which the entire type system becomes visible from a single vantage point.

The boundary is the forgetful functor F: 𝒯 → 𝒱 — the mapping from the type category to the value category. It is the mechanism by which Rust’s compile-time structure is transformed into runtime behaviour. Every feature of Rust’s type system, from newtypes to lifetimes, from blanket impls to typestate machines, passes through this mapping. What the functor preserves determines what Rust can do at runtime. What it erases determines what Rust gets for free.

The boundary is also where structures that were introduced separately — in different chapters, at different levels — reveal their common shape. Inherent impls, trait impls, and proof-carrying phantom types were each presented as distinct mechanisms. Viewed from the boundary, they are three expressions of a single principle: compile-time proof that the forgetful functor erases for free. The boundary chapter is therefore both an analysis of the functor’s mechanics and a synthesis of the proof system as a whole.

Understanding the boundary precisely — what crosses it, what does not, what partly crosses it, and at what cost — is the key to understanding Rust’s design as a whole. The boundary is not a line. It is a membrane with specific channels, each with its own direction, cost, and information capacity.

The Forgetful Functor

A functor, in the categorical sense, is a structure-preserving map between categories. It maps objects to objects, morphisms to morphisms, and respects composition: if two morphisms compose in the source category, their images compose in the target category.

The boundary F: 𝒯 → 𝒱 is a forgetful functor — it preserves some structure while deliberately discarding the rest. Every functor preserves something; what makes F forgetful is that many distinct objects in 𝒯 map to the same object in 𝒱. The functor “forgets” the distinctions between them.

The familiar newtype example makes this concrete:

#![allow(unused)]
fn main() {
struct Metres(f64);
struct Seconds(f64);
}

Chapter 1 established that F maps both to the same f64 — the type distinction that prevents dimensional confusion is exactly what the functor forgets. But in this chapter we can add a crucial framing: the forgetting is not information loss. The information has already done its work. The compiler verified that no Metres was used where a Seconds was expected. The proof is complete; the evidence is no longer needed. F discards it because retaining it would impose a runtime cost — and the zero-cost abstraction guarantee means that type-level structure must not survive as runtime overhead.

The forgetful functor has a systematic action at each level:

Level𝒯 (compile time)F𝒱 (runtime)
0Type identity (Metres ≠ Seconds)Erased (same bytes)
1Generic family (fn f<T>)Set of monomorphised functions
2Trait bounds (T: Ord)Erased (verified and discarded)
3Proof witnesses (impl Ord for T)Inlined code or vtable entries
4Phantom parameters, typestateZero bytes, zero instructions

The functor is faithful to computation — the runtime code does exactly what the type-level specification demanded — but forgetful of logic — the reasons, relationships, and proof structures that justified the computation vanish completely.

Monomorphisation: Collapsing the Family

Monomorphisation is the boundary’s most visible operation. It takes a generic function — a family of functions indexed by type parameters — and collapses it into a finite set of concrete functions, one for each type that the program actually uses.

Consider a generic function at Level 1:

fn wrap<T>(value: T) -> Option<T> {
    Some(value)
}

fn main() {
    let a = wrap(42_i32);
    let b = wrap("hello");
    let c = wrap(3.14_f64);
}

In 𝒯, wrap is a single object — a universally quantified function ∀T. T → Option<T>. It is one entity with a parametric structure. It does not exist as three separate functions; it exists as a family, a fibration over the base space of all types.

When F is applied, the family collapses:

F(wrap<T>) = { wrap_i32: i32 → Option<i32>,
               wrap_str: &str → Option<&str>,
               wrap_f64: f64 → Option<f64> }

Each member of the family becomes a separate function in 𝒱, with its own machine code, its own memory layout assumptions, its own calling convention. The parametric structure — the fact that these three functions are instances of a single universal specification — is gone. In 𝒱, they are unrelated functions that happen to have similar shapes.

What Monomorphisation Preserves and Discards

Monomorphisation preserves computational behaviour — each monomorphised function does exactly what the generic specification says it should do for its particular type. It preserves composition — if the generic code calls other generic functions, the monomorphised version calls the corresponding monomorphised versions. This is what makes F a functor, not merely a function: it respects the compositional structure of the source category.

Monomorphisation discards:

  • Universality: the fact that the function works for all T, not just the types used in this program
  • Parametricity constraints: the guarantee that the function treats all types uniformly
  • Family structure: the relationship between wrap_i32 and wrap_f64 as members of the same family
  • Proof obligations: any trait bounds that were checked and satisfied

The discarded information is substantial. In 𝒯, wrap and a hypothetical wrap_with_logging (which logs the type name before wrapping) have different types — the second cannot have type ∀T. T → Option<T> because it inspects T. After monomorphisation, both produce functions with the same signatures. The parametricity guarantee — the theorem that wrap must behave uniformly — exists only in 𝒯. F forgets it.

The Cost of Monomorphisation

Monomorphisation trades compile time and binary size for runtime performance. Each instantiation generates new code. A function used with 50 different types produces 50 copies. The compiler performs optimisations on each copy independently — inlining trait methods, specialising comparisons, eliminating dead branches — producing code that is as fast as hand-written Level 0 code for each specific type.

This trade-off is itself a property of the boundary. A different boundary — one that preserves type abstraction at runtime, like Haskell’s dictionary-passing or Java’s type erasure with boxing — would produce a single function body that works for all types at the cost of indirection. Rust’s boundary chooses the other end of the spectrum: maximum code generation, zero abstraction overhead.

The choice is not neutral. It determines what Rust can and cannot express efficiently. A data structure that is generic in 100 different types (rare in practice, but possible) generates 100 copies of every method. The boundary does not offer a middle ground — there is no “monomorphise these but dictionary-pass those” option at the language level. The boundary’s strategy is uniform.

Dynamic Dispatch: Projecting Through the Boundary

Monomorphisation is F’s primary mechanism, but not its only one. Dynamic dispatch via dyn Trait is a second mechanism with fundamentally different properties.

When you write Box<dyn Display>, you are constructing an object that carries type information across the boundary — but in a degraded, partial form.

use std::fmt;

fn print_it(value: &dyn fmt::Display) {
    println!("{value}");
}

fn main() {
    let n: i32 = 42;
    let s: String = "hello".to_string();

    print_it(&n);
    print_it(&s);
}

In 𝒯, print_it takes a reference to any type that satisfies Display. In 𝒱, the function takes a fat pointer: a pair consisting of a data pointer (pointing to the value’s bytes) and a vtable pointer (pointing to a table of function pointers implementing the trait methods).

The vtable is the interesting structure. It is what remains of the proof after F is applied:

Proof componentIn 𝒯After F (dyn)
Type identityKnown (e.g. i32)Erased
Method implementationsAbstract trait methodsFunction pointers in vtable
Supertrait relationshipsVerified chainErased (vtable includes only this trait)
Associated typesResolved to concreteErased (not accessible through dyn)
Other trait implementationsFull lattice positionInaccessible

The vtable is a partial projection of the proof into 𝒱. It preserves the computational content — you can call the trait methods — while erasing the logical content — you cannot ask which type is behind the pointer, what other traits it satisfies, or how the proof was derived.

Monomorphisation versus Dynamic Dispatch

The two mechanisms are complementary projections of the same type-level structure:

PropertyMonomorphisationDynamic dispatch
Type identityResolved to concreteErased
Code generatedOne copy per typeOne copy, shared
Dispatch costZero (inlined)Indirect call through vtable
Binary sizeGrows with instantiationsConstant
OptimisationFull (specialised per type)Limited (no type-specific optimisation)
Type information at runtimeNone (erased)Partial (vtable)

Monomorphisation is F applied fully: everything in 𝒯 is resolved, specialised, and discarded. The result in 𝒱 is as if the generic code never existed — pure concrete computation.

Dynamic dispatch is F applied partially: the type identity is erased, but the trait interface survives as a vtable. The result in 𝒱 retains a trace of the type-level structure — enough to call methods, not enough to recover the original type.

In Reynolds’ terminology, monomorphisation is full defunctionalisation — every abstract operation is replaced by a concrete implementation chosen at compile time. Dynamic dispatch is partial defunctionalisation — abstract operations are replaced by a dispatch table, with the concrete selection deferred to runtime.

The Permeability Spectrum

The boundary is not a wall. It is a membrane with specific channels — mechanisms by which information can cross between 𝒯 and 𝒱. These channels vary in their direction, bandwidth, and cost.

PhantomData: 𝒯-Only Information

PhantomData<T> is a zero-sized type that exists purely in 𝒯. The forgetful functor maps it to nothing — zero bytes, zero instructions. It is a one-way valve: type information enters 𝒯 through the phantom parameter, influences compilation (which methods are available, which trait bounds are checked, which type equalities hold), and then vanishes at the boundary.

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

struct Token<Permission> {
    id: u64,
    _permission: PhantomData<Permission>,
}

struct ReadOnly;
struct ReadWrite;

impl Token<ReadOnly> {
    fn read(&self) -> u64 { self.id }
}

impl Token<ReadWrite> {
    fn read(&self) -> u64 { self.id }
    fn write(&mut self, new_id: u64) { self.id = new_id; }
}
}

In 𝒱, Token<ReadOnly> and Token<ReadWrite> are identical: a single u64. The phantom parameter occupies zero bytes. The distinction between read-only and read-write — the entire permission model — exists only in 𝒯. The boundary erases it completely, leaving behind only the guarantee that the permission rules were respected.

PhantomData is the boundary’s most extreme channel: maximum information in 𝒯, zero presence in 𝒱. It is the purest expression of zero-cost abstraction.

Const Generics: Values Crossing Upward

Const generics are the boundary’s only channel for information flowing from 𝒱 into 𝒯. A const generic parameter is a value — a scalar from the value domain — that appears in a type.

#![allow(unused)]
fn main() {
fn dot_product<const N: usize>(a: &[f64; N], b: &[f64; N]) -> f64 {
    let mut sum = 0.0;
    let mut i = 0;
    while i < N {
        sum += a[i] * b[i];
        i += 1;
    }
    sum
}
}

The parameter N is a usize — a value — but it appears in the type [f64; N]. This is the third axis of the lambda cube: types depending on terms. The value N crosses upward from 𝒱 into 𝒯, influencing type-level computation (the array size is part of the type).

When F is applied, the const parameter collapses to a literal: dot_product::<3> becomes a function operating on [f64; 3], with N = 3 baked into the code as a constant. The parameter’s journey is: born as a value, lifted into a type, and then lowered back into a concrete literal by monomorphisation.

This is Rust’s only dependent-type mechanism. It is heavily restricted: only scalar types can serve as const generic parameters, and type-level arithmetic on them requires nightly features. The restriction exists because the boundary demands that all type-level computation be resolvable at compile time. Arbitrary value-to-type dependencies would require evaluating runtime expressions during type checking — which would dissolve the boundary entirely.

TypeId: A Narrow Read-Only Channel

TypeId provides a minimal downward channel: runtime code can query the identity of a type, but cannot act on it in a type-safe way.

#![allow(unused)]
fn main() {
use std::any::TypeId;

fn is_string<T: 'static>() -> bool {
    TypeId::of::<T>() == TypeId::of::<String>()
}
}

TypeId is a hash of the type’s identity, available at runtime. It allows equality comparisons — “is this the same type as that?” — but nothing more. You cannot recover the type’s methods, its trait implementations, or its structure from a TypeId. It is a fingerprint, not a photograph.

In the categorical model, TypeId is a section of the forgetful functor — a partial inverse that maps a runtime value back to a type-level identity. But it is an extremely weak section: it recovers only the identity, not any of the structure that F erased. The functor forgets the type lattice position, the proof witnesses, the parametric structure. TypeId remembers only the name.

dyn Trait: Partial Projection Downward

As discussed above, dyn Trait carries a partial projection of the proof domain into 𝒱. The vtable preserves method implementations as function pointers while erasing type identity and lattice position. The cost is an indirect function call per method invocation and a pointer-width overhead for the vtable reference.

The Spectrum

These four mechanisms, together with monomorphisation itself, form a spectrum of boundary permeability — extending the per-level view from the opening table to show each crossing’s direction and cost:

MechanismDirectionInformation preservedRuntime cost
PhantomData<T>𝒯 onlyFull type-level structureZero
Const generics𝒱 → 𝒯Scalar values in typesZero (monomorphised)
TypeId𝒯 → 𝒱 (read-only)Type identity onlyMinimal (hash comparison)
dyn Trait𝒯 → 𝒱 (partial)Method implementationsVtable pointer + indirect call
Monomorphisation𝒯 → 𝒱 (full erasure)Computational content onlyZero overhead, code size cost

Each mechanism represents a different trade-off between information preservation and runtime cost. PhantomData pays nothing because it carries nothing into 𝒱. Const generics pay nothing because monomorphisation resolves them to literals. TypeId pays a minimal cost for minimal information. dyn Trait pays a real cost — indirection, lost optimisation opportunities — for the ability to work with heterogeneous types at runtime.

The spectrum is not an accident. It reflects a design principle: the boundary permits exactly as much information to cross as the programmer explicitly requests, and charges exactly the runtime cost that the crossing requires. There is no hidden overhead, no implicit boxing, no surprise allocations. The cost of each crossing is visible in the syntax: dyn in the type signature, PhantomData in the struct definition, const N in the generic parameter list.

Lifetimes: The Most Dramatic Erasure

Lifetimes are the most striking example of the boundary’s forgetful nature. An entire sub-logic — a proof system for reference validity — operates in 𝒯, is verified by the borrow checker, and vanishes without a trace when F is applied.

#![allow(unused)]
fn main() {
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}
}

The lifetime annotations (elided here, inferred as fn first_word<'a>(s: &'a str) -> &'a str) assert: the returned reference is valid for the same region as the input reference. The borrow checker verifies this by tracking the reference’s provenance through the function body — the output is always a sub-slice of the input, so it necessarily lives as long as the input.

In 𝒱, after F is applied, the function takes a pointer and a length (the &str fat pointer) and returns a pointer and a length. There are no lifetimes. There are no validity proofs. The pointers are bare memory addresses, indistinguishable from C pointers — except that the lifetime proof, verified and erased, guarantees they are safe.

The Lifetime Sub-Logic

Chapter 2 described lifetimes as a sub-logic within 𝒯. Let us be precise about what this means.

The lifetime system has:

  • Propositions: lifetime bounds like 'a: 'b (“reference 'a outlives reference 'b”) and T: 'a (“type T’s data is valid for at least lifetime 'a”)
  • Inference rules: the borrow checker’s analysis, which derives lifetime relationships from the control flow of the program
  • A partial order: 'static is the top element (outlives everything), and the subtyping relationship 'long: 'short means a reference valid for 'long can be used where 'short is expected
  • Proof checking: the borrow checker verifies that all lifetime constraints are satisfiable — that no reference outlives its referent

This is a complete deductive system. It has hypotheses (lifetime annotations), inference rules (the borrowing rules), and theorems (the verified constraints). The borrow checker is the proof checker for this system.

And all of it is erased by F. Every lifetime annotation, every borrow check, every proof of reference validity — discarded at the boundary. The resulting machine code uses raw pointers. The safety guarantee is real, but the mechanism that established it has no runtime presence.

This is zero-cost abstraction at its most extreme. The lifetime system is arguably the most sophisticated part of Rust’s type system — the part that most distinguishes Rust from other languages — and it contributes exactly zero bytes and zero instructions to the compiled program. The entire proof system exists to justify the use of bare pointers instead of garbage collection or reference counting. The boundary erases the proofs and keeps the bare pointers.

Comparison with Other Memory Management Strategies

The lifetime erasure is best understood by contrast:

StrategyProof of validityRuntime presenceCost
Rust (lifetimes)Static proof, borrow checkerErased completelyZero
Garbage collection (Java, Go)Dynamic tracing at runtimeGC roots, pause timesThroughput + latency
Reference counting (Swift, Python)Dynamic count at runtimeCounter per allocationIncrement/decrement per copy
Manual (C, C++)Programmer’s informal reasoningNoneZero, but unsound

Rust and C have the same runtime cost for memory management: zero. The difference is that Rust’s zero cost is backed by a verified proof, while C’s zero cost is backed by the programmer’s fallible judgement. The boundary enables this: by hosting the proof entirely in 𝒯, Rust gets the safety of garbage collection with the cost profile of manual management. The boundary is the mechanism that makes this possible.

The World Stack: Davies and Pfenning Revisited

Chapter 1 introduced Davies and Pfenning’s modal analysis, which frames compile time and runtime as possible worlds connected by an accessibility relation. At the boundary chapter, this framing earns its full weight.

In the modal logic interpretation:

  • Compile time is the current world — the world of reasoning, proof, and type-level computation.
  • Runtime is an accessible future world — the world where computation actually occurs.
  • The necessity operator (□) marks information available in the current world that will persist into the future world. Constants, type-level decisions baked into monomorphised code, and vtable layouts are all □-typed: they are determined now and available later.
  • The possibility operator (◇) marks information that exists only in the future world. User input, I/O results, and dynamically computed values are ◇-typed: they cannot be known at compile time.

The boundary F is the transition between worlds. Crossing the boundary means moving from the current world (where type-level reasoning is possible) into the future world (where only computation occurs). What survives the transition is precisely the □-information: the necessary truths that the current world has established.

What the Modal Framing Reveals

The modal framing clarifies several features of the boundary:

Lifetimes are current-world proofs about the future world. When you annotate a function with lifetime 'a, you are asserting (in the current world, at compile time) that a reference will be valid during a region of execution in the future world (at runtime). The borrow checker verifies this assertion in the current world. The assertion is then erased — it was about the future world, but the future world does not need to check it again. The proof has already been established.

Const generics are future-world values pulled into the current world. A const generic N: usize takes a value that would normally be a future-world entity (a runtime integer) and lifts it into the current world (a type-level parameter). This is the possibility operator’s limited inverse: ◇-information (a runtime value) is made into □-information (a compile-time constant) by restricting it to values that can be determined statically.

dyn Trait is □-information about the future world. A vtable is a compile-time decision (which methods to include, how to lay them out) that persists into the future world as a data structure. It is necessarily true (□) that the vtable contains the correct function pointers — this was verified at compile time and cannot change at runtime.

Moggi’s Computational Monads and the Boundary

Eugenio Moggi’s 1991 work on computational monads provides another lens on the boundary. Moggi showed that computational effects — state, exceptions, I/O, non-determinism — can be modelled by distinguishing between values (pure data) and computations (processes that may have effects). A monad is the mathematical structure that mediates between the two.

In Rust’s two-category model, the distinction between values and computations maps onto the distinction between 𝒱’s pure objects (data in memory) and 𝒱’s effectful morphisms (functions that may allocate, panic, perform I/O, or diverge). The boundary itself is not a monad, but it shares a structural similarity with monadic mediation: just as a monad separates “what the value is” from “how it is computed,” the boundary separates “what the type says” from “how the computation runs.”

The connection becomes concrete in Rust’s treatment of Result and Option. These types are not monads in the Haskell sense — Rust has no Monad trait, as Chapter 7 explained — but they encode computational effects (failure, absence) as values. The ? operator performs monadic bind: it sequences computations that may fail, propagating errors through the return type. This is effect handling through the type system — an effect encoded in 𝒯 (the Result type) and checked at compile time (the ? operator’s type constraints), with the effect’s runtime mechanism (early return on error) present in 𝒱.

Moggi’s framework helps explain why Rust encodes effects in types rather than in a separate effect system: Rust’s boundary demands that all compile-time structure be resolvable to concrete runtime code. An effect system would require the boundary to translate abstract effect annotations into concrete effect-handling code — which is precisely what Rust does with Result and ?, but without a separate effect language. The effect is the type. The handling is the code. The boundary has nothing extra to erase.

Programming Style as Altitude

Chapter 0 introduced the idea that programming style corresponds to a preferred altitude in the level hierarchy. The boundary chapter is the right place to make this precise, because the boundary is what connects altitude to runtime cost.

Level 0: Below the Boundary

The C-style programmer works primarily in 𝒱. Types are data layout specifications. Functions are concrete transformations. There is minimal type-level structure to erase, so the boundary is nearly invisible. The code you write is close to what the compiler emits.

#![allow(unused)]
fn main() {
fn clamp(value: f64, min: f64, max: f64) -> f64 {
    if value < min { min } else if value > max { max } else { value }
}
}

No generics, no bounds, no proofs to erase. The boundary crossing is trivial: the 𝒯 representation (the type f64) maps directly to the 𝒱 representation (eight bytes). The programmer pays no abstraction cost because there is no abstraction.

Levels 1–2: The Boundary Works Automatically

The generic programmer works in 𝒯, writing families of functions parameterised by types and bounds. The boundary does the heavy lifting: monomorphisation generates concrete code, trait bounds are verified and erased, proof witnesses are consumed and inlined.

#![allow(unused)]
fn main() {
fn clamp<T: PartialOrd>(value: T, min: T, max: T) -> T {
    if value < min { min } else if value > max { max } else { value }
}
}

The same logic, now universal. The boundary generates a separate clamp for each type used. Each generated function is identical in quality to the Level 0 version — the abstraction cost is zero at runtime, paid only at compile time (type checking, monomorphisation) and in binary size.

This is the altitude where most Rust programmers work most of the time, and it is the altitude where the boundary’s zero-cost guarantee is most visibly at work.

The dyn Boundary: Explicit Crossing

The dyn Trait programmer explicitly engages the boundary, choosing to preserve trait-level structure at runtime:

#![allow(unused)]
fn main() {
fn print_all(items: &[&dyn std::fmt::Display]) {
    for item in items {
        println!("{item}");
    }
}
}

Here the boundary is visible in the syntax: dyn Display marks the point where type identity is erased and replaced by a vtable. The programmer accepts the cost (indirection, lost monomorphisation) in exchange for the ability to store heterogeneous types in a single collection.

This is the boundary used as a tool — a deliberate decision about how much type information to preserve at runtime.

Level 4: Maximum Altitude

The typestate programmer works at the highest altitude in 𝒯, encoding state machines and invariants in phantom types. The boundary erases everything:

use std::marker::PhantomData;

struct Locked;
struct Unlocked;

struct Door<State> {
    name: String,
    _state: PhantomData<State>,
}

impl Door<Locked> {
    fn unlock(self) -> Door<Unlocked> {
        Door { name: self.name, _state: PhantomData }
    }
}

impl Door<Unlocked> {
    fn open(&self) {
        println!("Opening {}", self.name);
    }
    fn lock(self) -> Door<Locked> {
        Door { name: self.name, _state: PhantomData }
    }
}

fn main() {
    let door: Door<Locked> = Door {
        name: "Front".to_string(),
        _state: PhantomData,
    };
    let door = door.unlock();
    door.open();
    let _door = door.lock();
}

In 𝒱, every Door<State> is the same: a String. The lock/unlock transitions are zero-cost state changes — the state exists only in 𝒯, and the boundary erases it completely. The maximum altitude produces the maximum erasure: an entire state machine verified at compile time, contributing nothing to the runtime.

The Altitude Trade-Off

AltitudeType-level richnessBoundary workRuntime costFlexibility
Level 0MinimalTrivialNoneMaximum runtime flexibility
Level 1–2ParametricMonomorphisationZero (code size cost)Static polymorphism
dyn TraitPartialVtable projectionIndirection costRuntime polymorphism
Level 4MaximumFull erasureZeroCompile-time guarantees only

The trade-off is not linear. Higher altitude does not simply cost more. Levels 1–2 and Level 4 are both zero-cost — the boundary erases them completely. The cost appears only when the programmer requests runtime polymorphism via dyn Trait, which is the boundary’s partial-projection mode. The choice of altitude is a choice about where the polymorphism lives: in 𝒯 (resolved at compile time, zero cost) or partly in 𝒱 (resolved at runtime, indirection cost).

Three Proof Mechanisms and the Boundary

The preceding chapters introduced three distinct mechanisms by which Rust programs carry proof. Each mechanism operates at a different level, encodes a different class of proposition, and interacts with the boundary in a different way. But the chapters presented them separately — inherent impls in Chapter 3, trait impls in Chapter 6, phantom types in Chapter 7 — and never placed them side by side. The boundary is the right lens for unifying them, because the boundary is where their differences become concrete: each proof mechanism is defined, in part, by what the forgetful functor does to it.

Mechanism 1: Inherent Impl Blocks — Unnamed Implications

Chapter 3 presented the inherent impl block as a ground proposition. Each method is a proof of an implication: fn area(&self) -> f64 proves “given a Circle, an area exists.” The impl block as a whole is a collection of such implications — a bundle of claims about what the type can do.

These propositions are unnamed. There is no predicate, no trait, no label that other code can reference. You cannot write a bound T: HasArea based on the existence of an inherent area method. The propositions exist, and the proofs are valid, but they are invisible to the constraint system. They cannot participate in quantification.

At the boundary, inherent methods survive as concrete functions in 𝒱 — specific sequences of machine instructions with specific calling conventions. The functor preserves their computational content fully. There is nothing to erase: no generics, no bounds, no proof witnesses. The methods were concrete in 𝒯 and remain concrete in 𝒱. Of the three mechanisms, this is the one the boundary touches least.

Mechanism 2: Trait Impl Blocks — Named Propositions

Chapter 6 presented impl Trait for Type as a proof of a named proposition. The trait is the predicate; the impl is the proof term; the orphan rule ensures coherence. Unlike inherent methods, trait propositions are named — they can be referenced in bounds, enabling quantification. When a function signature says T: Display, it references the Display predicate by name, and the caller must supply a proof (an impl) that the predicate holds for their concrete type.

The naming is what connects Level 2 (bounds as predicates) to Level 3 (impls as proofs). Without named propositions, there would be no way for a generic function to demand specific capabilities — it could only accept any T and do nothing with it (Level 1) or work with a specific concrete type (Level 0).

At the boundary, trait impls are erased — but their computational content is preserved. Under monomorphisation, the functor resolves each trait method call to a concrete function call, inlines it, and discards the proof. Under dynamic dispatch, the proof survives partially as a vtable — a table of function pointers extracted from the impl. In either case, the logical structure (the fact that a predicate was satisfied) vanishes. Only the operational content (the code that runs) remains.

Mechanism 3: Proof-Carrying Types — Existence as Evidence

The third mechanism is the most subtle: a type whose very existence constitutes proof that some property holds. This is not about what methods a type has, or what traits it satisfies, but about the fact that a value of that type could only have been constructed if certain conditions were met.

Chapter 7 developed one version of this: typestate with PhantomData, where the phantom parameter encodes a verified property. Connection<Authenticated> carries no runtime evidence of authentication — the proof is the type itself. The only way to obtain a Connection<Authenticated> is through the authenticate method, which checks credentials and transitions the state. The type system ensures that no code path can produce an Authenticated connection without passing through the check.

But the pattern is broader than typestate. Consider a newtype wrapper that certifies a runtime property:

#![allow(unused)]
fn main() {
/// A non-empty collection. The only way to construct this
/// value is through `try_new`, which verifies non-emptiness.
struct NonEmpty<T> {
    items: Vec<T>,
}

impl<T> NonEmpty<T> {
    fn try_new(items: Vec<T>) -> Option<Self> {
        if items.is_empty() {
            None
        } else {
            Some(NonEmpty { items })
        }
    }

    /// Safe: the constructor guarantees at least one element.
    fn first(&self) -> &T {
        &self.items[0]
    }
}
}

Under Curry-Howard, NonEmpty<T> is not merely a conjunction (a Vec<T> and nothing else). It is a proof-carrying type: its existence is evidence that the contained vector is non-empty. The proof was established at construction time (the try_new check), and the type carries that proof forward through all subsequent code. Any function that receives a NonEmpty<T> can rely on non-emptiness without re-checking it — the type is the proof.

This reading applies wherever construction is guarded by validation: NonZero<u32> in the standard library, domain types like EmailAddress or PortNumber that parse and validate on construction, the Validated<T> conjunction from Chapter 2 (which is simultaneously a product type and a proof witness — its existence certifies that validation passed). In each case, the proposition is: “this property was verified.” The proof is: “a value of this type exists.”

At the boundary, proof-carrying types behave like ordinary values — the functor maps them to their runtime representation (a Vec<T>, a u32, a struct). The proof structure is erased entirely. In 𝒱, a NonEmpty<Vec<i32>> is indistinguishable from a Vec<i32>. The guarantee is real, but the evidence for it has vanished — consumed by the type checker, verified once, and discarded.

The Three Mechanisms Compared

Inherent implTrait implProof-carrying type
Proposition1. Unnamed implications2. Named predicate3. Existence of a type
ProofMethod bodyimpl blockGuarded constructor
Level0 (ground)2–3 (bounds + impls)0–4 (any level)
Referenceable in bounds?NoYesIndirectly (via trait bounds on the wrapper)
Boundary actionMethods survive as functionsErased (monomorphised) or partial (vtable)Type identity erased; value survives
Runtime cost of proofZeroZero (monomorphised) or indirection (dyn)Zero (checked at construction)

The three mechanisms are complementary, and idiomatic Rust often combines them. The typestate pattern from Chapter 7 is a clear example: a phantom parameter carries the state proposition (Mechanism 3), state-specific inherent impls make methods conditionally available (Mechanism 1), and trait bounds on the state parameter can constrain which states support which operations (Mechanism 2). All three proof mechanisms operate simultaneously, at different levels of the type category, and the boundary erases all of them to the same concrete representation in 𝒱.

The unifying principle is the boundary itself: Rust permits multiple forms of compile-time proof because the forgetful functor erases all of them equally. The richness of the proof system is possible precisely because it imposes no runtime cost. Whether the proof is a method body, an impl block, or the guarded construction of a value, the boundary discards the logical structure and preserves only the computation. The proofs are free, and so the language can afford to have many kinds.

What the Boundary Cannot Cross

Some information is structurally unable to cross the boundary in either direction.

Types cannot cross downward as values (except via TypeId). You cannot pattern-match on a type at runtime, cannot branch on whether T is i32 or String, cannot dispatch based on type identity. Types are 𝒯-only entities. (The Any trait provides a narrow exception via downcasting, but this is a controlled escape hatch, not a general mechanism.)

Runtime values cannot cross upward into types (except via const generics). You cannot use a runtime integer as an array size, a runtime string as a type name, or a runtime boolean as a type-level condition. The type system is closed before the program runs.

Proofs cannot cross downward as data (except via vtables). You cannot store an impl Ord for T in a variable, pass it as a function argument, or inspect it at runtime. Proofs are 𝒯-only entities that are consumed during compilation.

Effects cannot cross upward into types (Rust has no effect system). A function’s type does not indicate whether it allocates memory, performs I/O, panics, or diverges. The Result and Option types encode specific failure modes, but there is no general mechanism for reflecting effects in the type system. This is a deliberate boundary constraint: encoding arbitrary effects in types would require the boundary to translate them, adding complexity to both 𝒯 and the boundary itself.

These restrictions define the boundary’s shape. They are not limitations to be worked around — they are the structural properties that enable zero-cost abstraction. If types could cross downward, they would need runtime representation. If runtime values could cross upward freely, type checking would require program execution. If proofs could cross downward, they would have a runtime cost. Each restriction removes a class of runtime overhead by preventing a class of boundary crossing.

The Boundary in Other Languages

Rust’s boundary has a specific character: aggressive erasure, zero-cost abstraction, full monomorphisation. Other languages draw the boundary differently. Examining these alternatives clarifies what Rust’s boundary costs and what it buys.

Haskell: Dictionary-Passing Boundary

Haskell’s boundary preserves more information than Rust’s. Type class constraints are compiled to dictionaries — records of method implementations passed as implicit arguments at runtime. Where Rust’s boundary erases proofs, Haskell’s boundary preserves them as data structures.

-- Haskell: the constraint survives as a runtime dictionary
sort :: Ord a => [a] -> [a]
-- compiles to approximately:
sort :: OrdDict a -> [a] -> [a]

The dictionary is a proof of Ord a that exists at runtime — it carries the comparison function as a field. This means Haskell’s polymorphic functions are truly polymorphic at runtime: a single compiled function works for all types, consulting the dictionary for type-specific operations.

The trade-off: Haskell’s boundary preserves abstraction at the cost of indirection. Every polymorphic call requires a dictionary lookup. Rust’s boundary erases abstraction at the cost of code duplication. Neither is universally superior — they are different boundary designs optimised for different priorities.

Java: Type-Erased Boundary

Java’s generics use type erasure — but a different kind from Rust’s. Java erases type parameters to their bounds (or to Object if unbounded) and inserts runtime casts at usage sites. The result is a single compiled method for all type parameter instantiations, working on boxed objects.

// Java: erased to Object, cast at usage
<T> T identity(T x) { return x; }
// compiles to approximately:
Object identity(Object x) { return x; }

Java’s boundary is simpler than Rust’s — no monomorphisation, no code duplication — but it forces boxing (heap allocation for primitives) and runtime casts (checked type conversions). The abstraction has a cost in both performance and safety (unchecked casts in pre-generics code). Java’s boundary was designed for backward compatibility, not zero-cost abstraction.

Idris: Programmable Boundary

Idris offers what Rust does not: programmer control over the boundary. In Idris, the programmer can annotate individual types and proofs with erasure annotations, specifying which compile-time structures should be retained at runtime and which should be erased.

-- Idris: explicit erasure annotation
myFunc : {0 prf : Eq a} -> a -> a -> Bool

The 0 before prf indicates that the proof argument is used zero times at runtime — it is compile-time only. Idris’ boundary is programmable: the programmer decides, for each piece of type-level structure, whether it crosses the boundary.

This is possible because Idris is dependently typed: proofs and types can appear in runtime positions, so the question of what to erase is a genuine choice, not a foregone conclusion. In Rust, the boundary’s behaviour is determined by the language: all proofs are erased, all generics are monomorphised. In Idris, the boundary is a dial that the programmer can turn.

Coq: No Boundary (Almost)

In Coq, the boundary between types and values is nearly dissolved. Types, proofs, and programs inhabit the same universe. A proof is a value; a type is a value; a function can take a proof as an argument and return a type as a result.

When Coq code is extracted to a conventional language (OCaml, Haskell), an extraction boundary is imposed — proofs marked as Prop (logical) are erased, while proofs marked as Set (computational) are retained. But within Coq itself, there is no boundary. The type category and the value category are the same category.

This is the Calculus of Constructions at work — the top vertex of the lambda cube, where all three axes are active and all four dependencies (terms on terms, terms on types, types on types, types on terms) are available. The boundary’s dissolution is the logical consequence of full dependent typing: if types can depend on runtime values, and values can depend on types, the distinction between compile time and runtime becomes untenable.

The Spectrum of Boundaries

LanguageBoundary styleWhat survivesCost
CNo type erasure (no generics)N/AN/A
JavaType erasure + boxingCasts, boxed representationsBoxing overhead
HaskellDictionary passingMethod dictionariesIndirection per call
RustFull monomorphisationNothing (all erased)Code size
IdrisProgrammer-controlled erasureWhatever is annotatedVaries
CoqNear-dissolution (extraction)Whatever is computationalFull representation

Rust occupies the most aggressive erasure position among production languages. This is a principled choice: Rust is a systems language, and systems languages are defined by their commitment to predictable, minimal-overhead execution. The boundary is the mechanism that fulfils this commitment while permitting sophisticated type-level reasoning.

The Boundary as Design Centre

The preceding chapters have presented the boundary as a transformation applied after the type-level work is done — a final step that collapses 𝒯 into 𝒱. But from a design perspective, the relationship is reversed: the boundary came first.

Rust was designed around the zero-cost abstraction principle: type-level structure must not impose runtime overhead. This principle is the boundary. The type system was built to be compatible with full erasure. The choice of monomorphisation over dictionary-passing, the orphan rule’s enforcement of coherence, the restriction to compile-time-resolvable generics — all of these are consequences of a boundary that demands total erasure.

The boundary is not a feature of Rust’s type system. It is the architectural constraint that shapes the type system. The type system is the way it is — with its particular strengths (zero-cost generics, lifetime safety, typestate encoding) and its particular limitations (no higher-kinded types, no runtime proof manipulation, no effect system) — because the boundary requires it.

This is the deepest insight of the two-category model. 𝒯 and 𝒱 are not independent categories that happen to be connected by a functor. 𝒯 is designed for the functor — designed to contain exactly the structure that can be usefully verified at compile time and then erased without trace. The boundary is not the last step in the pipeline. It is the first decision in the design.

Looking Beyond

The boundary defines what Rust can express. Everything within the boundary — the five levels, the type lattice, the proof domain, the typestate machines — exists because it is compatible with zero-cost erasure. Everything beyond the boundary — dependent types, first-class proofs, effect systems, universe polymorphism — is excluded because it would require the boundary to change.

But what the boundary permits is substantial. The next two chapters show what the five levels can build when deployed together against a single domain problem: a domain core whose invariants are proved by construction, wired into an architecture whose ring boundaries are enforced by the Cargo dependency graph. The theory is not abstract indulgence — it is a precise instrument for real software design. The landscape chapter that follows will then place these capabilities in the broader space of type systems, mapping the territories Rust does not enter and the reasons it stays where it does.

Domain Modelling as Proof Construction

The five levels of Rust’s type hierarchy are not merely a taxonomy. They are a vocabulary — a compositional system for expressing domain models as sets of propositions proved by construction. The preceding chapters derived each level independently: newtypes as zero-cost distinctions, generics as universal quantification, trait bounds as predicates, impl blocks as proofs, type constructors as higher-order operations. This chapter shows what happens when you deploy all five levels simultaneously against a single domain problem.

The thesis is practical: a well-modelled domain core in Rust is a collection of propositions about the domain, encoded in 𝒯, verified by the compiler, and erased at the boundary. Invalid states are not caught at runtime — they are unrepresentable. Business rules are not checked by if statements — they are proved by type structure. The compiler does not merely prevent memory errors; it prevents domain errors, with the same zero-cost guarantee.

To make this concrete, we will build one domain model from start to finish: a procurement request system. Each section adds one level’s contribution. By the chapter’s end, the model is complete — a domain core that proves its own invariants.

The Domain

A procurement request follows a lifecycle. Someone drafts a request for goods or services, specifying an amount and a reason. The draft is submitted for approval. A reviewer examines it. The reviewer either approves or rejects the request. An approved request is eventually closed when the goods are received. The rules are:

  • Every request has a unique identifier, a positive monetary amount, a reason, and an author.
  • Only a draft can be submitted.
  • Only a submitted request can be reviewed.
  • A review results in approval or rejection — the reviewer cannot defer indefinitely.
  • Only an approved request can be closed.
  • A rejected request is terminal.

These are domain propositions. The question is where they live: in 𝒯, where the compiler proves them, or in 𝒱, where runtime code checks them.

The Weak Model

Consider a first attempt — the kind of model that emerges when a programmer thinks only about data:

// Deliberately weak — do not emulate
struct ProcurementRequest {
    id: u64,
    amount: f64,
    reason: String,
    status: String,
    reviewer: Option<String>,
}

impl ProcurementRequest {
    fn submit(&mut self) {
        if self.status == "draft" {
            self.status = "submitted".to_string();
        }
    }

    fn approve(&mut self, reviewer: &str) {
        if self.status == "submitted" {
            self.status = "approved".to_string();
            self.reviewer = Some(reviewer.to_string());
        }
    }
}

Diagnose this type-theoretically. The domain propositions exist — “only a draft can be submitted” — but they live in 𝒱, encoded as string comparisons at runtime. The type String for status carries no information in 𝒯; the compiler cannot distinguish a draft from an approved request. The proposition “amount is positive” is unencoded entirely. The relationship between states is implicit in scattered if checks, invisible to the type system.

In the two-category model, this design places domain logic below the boundary where the compiler cannot help. The goal of this chapter is to lift it above.

The Proposition Inventory

Before writing any types, extract the domain’s propositions in natural language and classify them. Three kinds of domain statement map to different levels:

Invariants are properties that hold of a single value at all times.

  • “A request identifier is a valid UUID” — property of a type
  • “A monetary amount is positive” — property of a type
  • “A reason is non-empty” — property of a type

These are Level 0 propositions. They are proved by the type’s construction and maintained by the type’s API surface.

Contracts are relationships between states or between types.

  • “A request can only be submitted if it is in draft state” — state transition rule
  • “A request can only be approved if it has been reviewed” — state ordering
  • “A rejected request permits no further transitions” — terminal state

These are Level 4 propositions (typestate) or Level 2 propositions (trait bounds constraining which operations are available).

Capabilities are behavioural propositions — claims about what operations a type supports.

  • “A submitted request can be withdrawn by its author” — method availability
  • “Any request can be displayed for audit” — trait bound
  • “A request can be persisted to and retrieved from storage” — port contract

These are Level 2–3 propositions: trait definitions (Level 2) and their implementations (Level 3).

The proposition inventory for the procurement domain:

PropositionKindLevel
Request ID is a valid UUIDInvariant0
Amount is positiveInvariant0
Reason is non-emptyInvariant0
Approver ID is a valid UUIDInvariant0
Only drafts can be submittedContract4
Only submitted requests can be reviewedContract4
Review produces approval or rejectionContract4
Only approved requests can be closedContract4
Rejected requests are terminalContract4
Any request state can be displayedCapability2
Requests can be persistedCapability2–3
Domain errors have a defined vocabularyStructural0 (sum type)

This inventory drives every subsequent design decision. Each row becomes a type, a trait, a bound, or a phantom parameter.

Level 0: Named Inhabitants

Chapter 3 established that a newtype creates a distinction in 𝒯 that is erased at the boundary — Metres(f64) and Seconds(f64) have identical runtime representations but occupy different positions in the type lattice. For domain modelling, newtypes do something further: combined with a private field and a validating constructor, they prove a property at construction time and carry that proof silently thereafter.

Smart Constructors

A smart constructor is a function that validates input and returns a newtype only if the validation passes. The type’s field is private, so the only way to obtain a value is through the constructor. This means every value of the type satisfies the invariant — not by convention, but by construction.

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(uuid::Uuid);

impl RequestId {
    pub fn new() -> Self {
        RequestId(uuid::Uuid::new_v4())
    }

    pub fn parse(s: &str) -> Result<Self, RequestIdError> {
        let id = s.parse::<uuid::Uuid>().map_err(|_| RequestIdError)?;
        Ok(RequestId(id))
    }
}

impl fmt::Display for RequestId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

#[derive(Debug)]
pub struct RequestIdError;

impl fmt::Display for RequestIdError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "invalid request ID")
    }
}
// Minimal uuid stub for compilation:
mod uuid {
    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
    pub struct Uuid([u8; 16]);
    impl Uuid {
        pub fn new_v4() -> Self { Uuid([0; 16]) }
    }
    impl std::str::FromStr for Uuid {
        type Err = ();
        fn from_str(s: &str) -> Result<Self, ()> {
            if s.len() == 36 { Ok(Uuid([0; 16])) } else { Err(()) }
        }
    }
    impl std::fmt::Display for Uuid {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "00000000-0000-0000-0000-000000000000")
        }
    }
}
}

The proposition carried by RequestId is: “this value was produced by new (a fresh UUID) or parse (a validated string).” No code outside the module can construct a RequestId by other means — the field is private. The proof is established once at construction time and carried through the program without further checking.

The same pattern applies to the monetary amount:

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Amount(f64);

#[derive(Debug)]
pub struct AmountError;

impl Amount {
    pub fn new(value: f64) -> Result<Self, AmountError> {
        if value > 0.0 && value.is_finite() {
            Ok(Amount(value))
        } else {
            Err(AmountError)
        }
    }

    pub fn value(&self) -> f64 {
        self.0
    }
}

impl fmt::Display for Amount {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}", self.0)
    }
}

impl fmt::Display for AmountError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "amount must be positive and finite")
    }
}
}

Every Amount in the program is positive and finite. This is not a runtime check that might be forgotten — it is a structural guarantee. The proposition “amount is positive” has been lifted from 𝒱 (an if check scattered through business logic) to 𝒯 (a property of the type itself, proved at construction).

The same principle applies to ApproverId (a non-empty string newtype) and Reason (also a non-empty string newtype):

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ApproverId(String);

impl ApproverId {
    pub fn new(name: &str) -> Result<Self, ValidationError> {
        if name.trim().is_empty() {
            Err(ValidationError("approver ID must not be empty"))
        } else {
            Ok(ApproverId(name.to_string()))
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reason(String);

impl Reason {
    pub fn new(text: &str) -> Result<Self, ValidationError> {
        if text.trim().is_empty() {
            Err(ValidationError("reason must not be empty"))
        } else {
            Ok(Reason(text.to_string()))
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug)]
pub struct ValidationError(&'static str);

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl fmt::Display for ApproverId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl fmt::Display for Reason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}
}

None of these newtypes carries runtime overhead beyond the underlying representation. The forgetful functor maps Amount(f64) to f64, Reason(String) to String. The proofs — positivity, non-emptiness, valid UUID format — exist in 𝒯 and are erased at the boundary. This is the Level 0 contribution: named inhabitants that carry domain invariants at zero cost.

Levels 2–3: The Trait Lattice as Domain Contract

Chapters 5 and 6 established the relationship between trait bounds and impl blocks: a bound states a proposition (“for all T satisfying P”), and an impl block proves it (“here is the evidence that this specific type satisfies P”). In domain modelling, this machinery becomes a tool for expressing domain contracts — the capabilities and relationships that define what the domain can do.

Domain Traits as Predicates

Chapter 5 showed that a trait bound is a predicate over types: T: Ord asserts Ord(T), restricting T to totally ordered types. The same principle applies to domain-specific traits. A trait Submittable is a predicate: Submittable(T) asserts that values of type T can be submitted.

The design question is not whether to use traits — it is which propositions to encode as traits. Not every domain concept needs a trait. A trait is appropriate when:

  • Multiple types may satisfy the same capability (a trait as an abstraction boundary).
  • A capability must be available generically, without knowing the concrete type (a trait as a port).
  • A capability defines a contract that infrastructure must fulfil (a trait as a dependency inversion point).

For the procurement domain, the critical trait is the persistence contract — the port through which the domain core communicates with storage:

#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug, Clone)]
pub struct RequestId;
#[derive(Debug, Clone)]
pub struct Amount;
#[derive(Debug, Clone)]
pub struct Reason;
#[derive(Debug)]
pub struct ProcurementError;
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}
#[derive(Debug, Clone)]
pub struct RequestSnapshot {
    pub id: RequestId,
    pub amount: Amount,
    pub reason: Reason,
    pub status: String,
}
pub trait RequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError>;
    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError>;
}
}

The RequestSnapshot type (defined fully in the next section) is the serialisable representation of a procurement request — the data that crosses the persistence boundary. The trait is a proposition: “there exists a mechanism that can persist and retrieve procurement requests.” The domain core states this proposition; infrastructure proves it with a concrete implementation.

The ∀ versus ∃ Design Choice

Chapter 5 established the distinction between type parameters and associated types: a type parameter creates a relation in 𝒯 (one implementing type, potentially many parameter instantiations), while an associated type creates a function (one implementing type, exactly one associated type). This distinction, reframed in logical terms, becomes a critical design tool.

A generic type parameter is universal quantification: fn process<E: Event>(e: E) says “for all types E satisfying Event, this function can process E.” The function makes no commitment to a specific event type — it works for any.

An associated type is existential quantification at the impl level: trait Repository { type Entity; } says “there exists a type Entity such that this repository can persist it.” The existential is not free-floating — it is witnessed by each implementation. When you write impl Repository for PostgresOrderRepo { type Entity = Order; }, the impl proves: “there exists an entity type (namely Order) for which PostgresOrderRepo is a repository.” From the consumer’s perspective — a function bounded by fn f(repo: &impl Repository) — the entity type is determined by whichever impl is provided. The consumer does not choose it; it comes as part of the proof.

The distinction matters for domain design. Consider two formulations of the repository trait:

// Universal: Repository is parameterised over entity type
trait Repository<E> {
    fn save(&self, entity: &E) -> Result<(), Error>;
    fn find_by_id(&self, id: &str) -> Result<Option<E>, Error>;
}

// Existential: each repository determines its entity type
trait Repository {
    type Entity;
    fn save(&self, entity: &Self::Entity) -> Result<(), Error>;
    fn find_by_id(&self, id: &str) -> Result<Option<Self::Entity>, Error>;
}

The universal formulation (Repository<E>) allows a single type to implement Repository<Order> and Repository<Invoice> — it is a relation. This invites an incoherent design: a single database connection that handles every entity type, conflating distinct persistence concerns.

The existential formulation (type Entity) creates a functional dependency: each repository type is bound to exactly one entity type. The relationship is a function in 𝒯 — one input, one output — and the design is coherent by construction. A function that accepts repo: &impl Repository can use repo.save(entity) without knowing the concrete repository type, but the entity type is fixed by whichever implementation arrives:

use std::fmt;

#[derive(Debug, Clone)]
pub struct RequestId;
#[derive(Debug, Clone)]
pub struct Amount;
#[derive(Debug, Clone)]
pub struct Reason;

#[derive(Debug)]
pub struct ProcurementError;
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}

/// A snapshot of a procurement request's persistent state.
#[derive(Debug, Clone)]
pub struct RequestSnapshot {
    pub id: RequestId,
    pub amount: Amount,
    pub reason: Reason,
    pub status: String,
}

/// A generic repository: each implementor witnesses one Entity type.
pub trait Repository {
    type Entity;
    fn save(&self, entity: &Self::Entity) -> Result<(), ProcurementError>;
    fn find_by_id(&self, id: &str) -> Result<Option<Self::Entity>, ProcurementError>;
}

/// The procurement adapter witnesses Entity = RequestSnapshot.
struct InMemoryRequestRepo;

impl Repository for InMemoryRequestRepo {
    type Entity = RequestSnapshot;

    fn save(&self, _entity: &RequestSnapshot) -> Result<(), ProcurementError> {
        Ok(()) // simplified
    }

    fn find_by_id(&self, _id: &str) -> Result<Option<RequestSnapshot>, ProcurementError> {
        Ok(None) // simplified
    }
}

/// A consumer that is generic over any Repository whose Entity is RequestSnapshot.
fn count_all(repo: &impl Repository<Entity = RequestSnapshot>) -> Result<usize, ProcurementError> {
    // The bound Repository<Entity = RequestSnapshot> constrains the existential:
    // "I accept any repository, provided its witnessed entity type is RequestSnapshot."
    Ok(0) // simplified
}

fn main() {
    let repo = InMemoryRequestRepo;
    let _ = count_all(&repo);
}

The bound Repository<Entity = RequestSnapshot> is where the ∀ and ∃ meet. The function is universally quantified over repository implementations (“for all repos satisfying this trait”), but the associated type constrains the existential witness (“the entity type must be RequestSnapshot”). The caller provides the repository; the repository provides the entity type; the function works with both without knowing either concretely.

Three design positions. In practice, the associated-type Repository trait is most valuable when a codebase has many entity types sharing a common persistence contract. For a single domain like procurement, where only one entity type is persisted through the port, the trait can be specialised further — naming the entity type directly and dropping the associated type:

#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug, Clone)]
pub struct RequestId;
#[derive(Debug, Clone)]
pub struct Amount;
#[derive(Debug, Clone)]
pub struct Reason;
#[derive(Debug)]
pub struct ProcurementError;
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}
#[derive(Debug, Clone)]
pub struct RequestSnapshot {
    pub id: RequestId,
    pub amount: Amount,
    pub reason: Reason,
    pub status: String,
}
pub trait RequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError>;
    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError>;
}
}

This is the associated-type pattern taken to its logical conclusion: the functional dependency between repository and entity is so tight that the trait is named for the entity it serves. The entity type appears directly in the method signatures rather than through Self::Entity. The single-responsibility guarantee that the associated type enforced structurally is now enforced by the trait’s definition — a RequestRepository cannot serve invoices because its methods do not mention invoices.

The three positions form a spectrum:

DesignTrait Form∀/∃ CharacterWhen to Use
Type parameterRepository<E>∀ over entities — relationalRarely; invites incoherence
Associated typeRepository { type Entity; }∃ witness per impl — functionalMultiple entity types, shared contract
Domain-specificRequestRepositoryEntity fixed by definitionSingle entity per port — the common case

For the procurement domain, we use the domain-specific form. The remaining examples in this chapter and the next use RequestRepository directly.

Axioms, Inference Rules, and Derived Theorems

Chapter 6 showed that impl blocks form a proof system: ground impls prove propositions about specific types, and blanket impls derive new proofs from existing ones. This structure has a precise vocabulary:

Axioms are primitive type implementations — proofs that the standard library or the language provides without derivation:

// Axiom: u32 satisfies Ord (provided by std)
// impl Ord for u32 { ... }

Inference rules are blanket impls — proof constructors that produce new proofs from existing proofs:

// Inference rule: if T satisfies Ord, then Vec<T> satisfies Ord
// impl<T: Ord> Ord for Vec<T> { ... }

Derived theorems are the proofs that follow from combining axioms and inference rules. The compiler derives these automatically:

Axiom:           Ord(u32)
Inference rule:  ∀T. Ord(T) → Ord(Vec<T>)
Derived theorem: Ord(Vec<u32>)

In the procurement domain, the derivation tree looks like this:

Axiom:           Display(String)     [std]
Axiom:           Display(f64)        [std]
Ground proof:    Display(Amount)     [our impl: delegates to f64]
Ground proof:    Display(Reason)     [our impl: delegates to String]
Ground proof:    Display(RequestId)  [our impl: formats UUID]

Each ground proof (our impl Display for Amount) is a leaf in the derivation tree — it does not depend on other domain proofs, only on axioms from the standard library. As the domain grows, the derivation trees deepen:

Ground proof:    Debug(RequestId)    [derive macro]
Ground proof:    Debug(Amount)       [derive macro]
Ground proof:    Debug(Reason)       [derive macro]
Ground proof:    Debug(RequestSnapshot) [derive macro, depends on above three]

The derive attribute, as Chapter 6 discussed, is a proof generator: #[derive(Debug)] on RequestSnapshot generates impl Debug for RequestSnapshot by composing the Debug proofs of its fields. This is an inference rule instantiated by the compiler: “if all fields satisfy Debug, then the struct satisfies Debug.”

The orphan rule (Chapter 6) keeps this proof system consistent: you can only write impl Trait for Type if you own either the trait or the type. This prevents conflicting proofs — two different impls of the same trait for the same type — which would make the proof system unsound. In domain modelling, the orphan rule means your domain types can freely implement your domain traits, and the compiler guarantees no external crate can provide a conflicting implementation.

Level 4: Typestate for Domain Lifecycles

Chapter 7 introduced the typestate pattern with a Connection example: states encoded as phantom type parameters, transitions as functions that consume one state and produce another, invalid transitions rejected at compile time. For domain modelling, typestate encodes the lifecycle rules from the proposition inventory — the contracts that govern which operations are available in which states.

The Procurement State Machine

The procurement lifecycle has six states: Draft, Submitted, UnderReview, Approved, Rejected, and Closed. The transitions are:

Draft → Submitted → UnderReview → Approved → Closed
                                ↘ Rejected (terminal)

In typestate, each state is a zero-sized type, and the procurement request carries its state as a phantom parameter:

use std::marker::PhantomData;
use std::fmt;

// --- Domain value types (simplified from §9.2) ---
#[derive(Debug, Clone, PartialEq)]
pub struct Amount(f64);
impl Amount {
    pub fn new(value: f64) -> Result<Self, &'static str> {
        if value > 0.0 && value.is_finite() { Ok(Amount(value)) } else { Err("invalid") }
    }
    pub fn value(&self) -> f64 { self.0 }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reason(String);
impl Reason {
    pub fn new(text: &str) -> Result<Self, &'static str> {
        if text.trim().is_empty() { Err("empty") } else { Ok(Reason(text.to_string())) }
    }
    pub fn as_str(&self) -> &str { &self.0 }
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
impl RequestId { pub fn new(id: u64) -> Self { RequestId(id) } }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApproverId(String);
impl ApproverId {
    pub fn new(name: &str) -> Result<Self, &'static str> {
        if name.trim().is_empty() { Err("empty") } else { Ok(ApproverId(name.to_string())) }
    }
}

// --- States: zero-sized types, exist only in 𝒯 ---
pub struct Draft;
pub struct Submitted;
pub struct UnderReview;
pub struct Approved;
pub struct Rejected;
pub struct Closed;

// --- The procurement request, parameterised by state ---
pub struct ProcurementRequest<State> {
    id: RequestId,
    amount: Amount,
    reason: Reason,
    _state: PhantomData<State>,
}

impl ProcurementRequest<Draft> {
    pub fn new(id: RequestId, amount: Amount, reason: Reason) -> Self {
        ProcurementRequest {
            id,
            amount,
            reason,
            _state: PhantomData,
        }
    }

    pub fn submit(self) -> ProcurementRequest<Submitted> {
        ProcurementRequest {
            id: self.id,
            amount: self.amount,
            reason: self.reason,
            _state: PhantomData,
        }
    }
}

impl ProcurementRequest<Submitted> {
    pub fn begin_review(self) -> ProcurementRequest<UnderReview> {
        ProcurementRequest {
            id: self.id,
            amount: self.amount,
            reason: self.reason,
            _state: PhantomData,
        }
    }
}

/// The outcome of a review: the request branches into one of two states.
pub enum ReviewOutcome {
    Approved(ProcurementRequest<Approved>),
    Rejected(ProcurementRequest<Rejected>),
}

impl ProcurementRequest<UnderReview> {
    pub fn review(self, approved: bool, approver: ApproverId) -> ReviewOutcome {
        if approved {
            ReviewOutcome::Approved(ProcurementRequest {
                id: self.id,
                amount: self.amount,
                reason: self.reason,
                _state: PhantomData,
            })
        } else {
            ReviewOutcome::Rejected(ProcurementRequest {
                id: self.id,
                amount: self.amount,
                reason: self.reason,
                _state: PhantomData,
            })
        }
    }
}

impl ProcurementRequest<Approved> {
    pub fn close(self) -> ProcurementRequest<Closed> {
        ProcurementRequest {
            id: self.id,
            amount: self.amount,
            reason: self.reason,
            _state: PhantomData,
        }
    }
}

// Methods available in any state:
impl<State> ProcurementRequest<State> {
    pub fn id(&self) -> &RequestId {
        &self.id
    }

    pub fn amount(&self) -> &Amount {
        &self.amount
    }

    pub fn reason(&self) -> &Reason {
        &self.reason
    }
}

impl<State> fmt::Debug for ProcurementRequest<State> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ProcurementRequest")
            .field("id", &self.id)
            .field("amount", &self.amount.value())
            .field("reason", &self.reason.as_str())
            .finish()
    }
}

fn main() {
    let id = RequestId::new(1);
    let amount = Amount::new(100.0).unwrap();
    let reason = Reason::new("Office supplies").unwrap();
    let request = ProcurementRequest::new(id, amount, reason);
    let submitted = request.submit();
    let under_review = submitted.begin_review();
    let approver = ApproverId::new("Alice").unwrap();
    match under_review.review(true, approver) {
        ReviewOutcome::Approved(req) => { let _closed = req.close(); }
        ReviewOutcome::Rejected(_req) => {}
    }
}

The review method deserves attention. It returns ReviewOutcome, an enum of the two possible next states. This is the typestate pattern’s answer to branching transitions: when the next state depends on runtime input (the reviewer’s decision), the method returns a sum type whose variants are the possible successor states. The caller must match on the outcome, and each arm gives access to a request in the appropriate state. The compiler enforces that no code path can approve a request that was rejected or close one that was not approved.

The State Transition Category

The typestate encoding has a precise categorical interpretation. The states are objects in a small category. The transition methods are morphisms:

Objects:    Draft, Submitted, UnderReview, Approved, Rejected, Closed
Morphisms:  submit: Draft → Submitted
            begin_review: Submitted → UnderReview
            review(true): UnderReview → Approved
            review(false): UnderReview → Rejected
            close: Approved → Closed

The critical property: missing morphisms are illegal transitions. There is no morphism Draft → Approved, no morphism Rejected → Closed, no morphism Closed → anything. These transitions do not exist as methods; they cannot be called; the compiler rejects any attempt. The state machine’s correctness is not checked — it is constitutive. The types define what transitions exist, and everything else is excluded by absence.

Typestate versus Enum State

Chapter 7 presented the tradeoffs between typestate and enum-based state machines. For the procurement domain, this choice has practical consequences:

CriterionTypestateEnum state
Compile-time transition safetyYes — invalid transitions unrepresentableNo — requires runtime checks
Runtime state queriesDifficult — state is erased at boundaryNatural — match on discriminant
Serialisation/persistenceRequires conversion to serialisable formDirect — enum serialises naturally
Heterogeneous collectionsCannot store Vec<ProcurementRequest<_>>Can store Vec<ProcurementRequest>

The typestate approach excels for command-side logic where the state machine is traversed linearly: create, submit, review, close. The enum approach excels for query-side concerns: displaying a list of requests in various states, serialising to a database, reporting.

A well-designed domain core often uses both: typestate for the command path (where invalid transitions must be prevented) and an enum snapshot for the query path (where state must be inspected and stored). The RequestSnapshot type from §9.3 serves this purpose — it is the serialisable, queryable representation that complements the typestate model.

Error Types as Logical Connectives

Chapter 2 established the correspondence between logical connectives and Rust types: Result<T, E> is the disjunction T ∨ E, and ? is monadic bind that propagates the right disjunct. For domain modelling, this correspondence transforms error handling from an operational concern into a logical one.

Domain Errors as Propositions

A domain error type is a proposition about the ways an operation can fail. Each variant names a failure mode and carries the evidence:

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug)]
pub enum ProcurementError {
    /// The amount was not positive or not finite.
    InvalidAmount,
    /// The reason text was empty.
    EmptyReason,
    /// The requested state transition is not permitted.
    InvalidTransition { from: &'static str, to: &'static str },
    /// The request was not found in storage.
    NotFound { id: String },
    /// Storage is unavailable.
    StorageUnavailable { cause: String },
}

impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidAmount => write!(f, "amount must be positive and finite"),
            Self::EmptyReason => write!(f, "reason must not be empty"),
            Self::InvalidTransition { from, to } =>
                write!(f, "cannot transition from {from} to {to}"),
            Self::NotFound { id } => write!(f, "request {id} not found"),
            Self::StorageUnavailable { cause } =>
                write!(f, "storage unavailable: {cause}"),
        }
    }
}

impl std::error::Error for ProcurementError {}
}

The error enum is a closed-world disjunction (Chapter 3): the domain defines exactly the ways operations can fail, and the match on ProcurementError is exhaustive. If a new failure mode emerges, adding a variant forces every handler to be updated — the compiler tracks the proof obligation.

The Ring Boundary for Errors

Notice that ProcurementError has a StorageUnavailable variant. This is a domain-level description of an infrastructure failure. It is not sqlx::Error or std::io::Error — those are infrastructure types that the domain core must not know about. The adapter layer (Chapter 10) transforms infrastructure errors into domain errors:

// In the infrastructure adapter (not the domain core):
impl From<sqlx::Error> for ProcurementError {
    fn from(e: sqlx::Error) -> Self {
        ProcurementError::StorageUnavailable {
            cause: e.to_string(),
        }
    }
}

This From impl is a proof transformation: it converts evidence of an infrastructure failure (the proposition sqlx::Error) into evidence of a domain failure (the proposition ProcurementError::StorageUnavailable). The domain core never sees the infrastructure proof — it operates only with its own error vocabulary. The ? operator composes these proof transformations automatically: a function returning Result<T, ProcurementError> can use ? on any operation that returns a Result<_, E> where E: Into<ProcurementError>.

Result as Disjunction Elimination

A function returning Result<T, ProcurementError> presents the caller with a disjunction: either the operation succeeded (with evidence of type T) or it failed (with evidence of type ProcurementError). The caller must eliminate this disjunction — handle both cases — which is disjunction elimination in the Curry-Howard sense.

The ? operator is shorthand for a specific elimination strategy: “if the left disjunct, continue; if the right disjunct, propagate.” This is monadic bind specialised to the Result monad, and it composes: a chain of ? operations propagates the first failure, threading the success path through each step.

Algebraic Data Flows

The domain types defined so far — newtypes, state machines, error types — model the domain’s structure. But a domain also has flows: commands enter the system, events leave it, queries retrieve projections. These flows are algebraic types — sum types that define the vocabulary of interaction.

Commands as Sum Types

A command is a request to change the domain’s state. The set of possible commands is closed (the domain defines exactly what changes are permitted):

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
#[derive(Debug, Clone, PartialEq)]
pub struct Amount(f64);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reason(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApproverId(String);
#[derive(Debug)]
pub enum ProcurementCommand {
    CreateDraft {
        id: RequestId,
        amount: Amount,
        reason: Reason,
    },
    Submit {
        id: RequestId,
    },
    BeginReview {
        id: RequestId,
    },
    Review {
        id: RequestId,
        approved: bool,
        approver: ApproverId,
    },
    Close {
        id: RequestId,
    },
}
}

Events as Sum Types

An event records something that happened. Commands are imperative (“do this”); events are declarative (“this happened”). The event type mirrors the command type but in the past tense:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
#[derive(Debug, Clone, PartialEq)]
pub struct Amount(f64);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reason(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApproverId(String);
#[derive(Debug, Clone)]
pub enum ProcurementEvent {
    DraftCreated {
        id: RequestId,
        amount: Amount,
        reason: Reason,
    },
    Submitted {
        id: RequestId,
    },
    ReviewStarted {
        id: RequestId,
    },
    Approved {
        id: RequestId,
        approver: ApproverId,
    },
    Rejected {
        id: RequestId,
        approver: ApproverId,
    },
    Closed {
        id: RequestId,
    },
}
}

These sum types are closed-world disjunctions — Chapter 3’s insight applied to the flow layer. The compiler guarantees that every command handler covers every command, and every event listener covers every event. Adding a new command variant without updating all handlers is a compile-time error.

The Vertical Seam

In a conventional layered architecture, the primary seams are horizontal — between layers (controller, service, repository). The domain’s message types suggest a different organisation: the primary seams are vertical, around data flows.

A ProcurementCommand flows from the outside world (an HTTP handler, a CLI, a message queue) through an adapter that parses and validates it, into the domain core that processes it, producing ProcurementEvent values that flow outward to subscribers. The seam is the algebraic type boundary: the command enum defines what can enter; the event enum defines what can leave. The domain core’s public API is a function from commands to events:

fn handle(
    cmd: ProcurementCommand,
    repo: &impl RequestRepository,
) -> Result<Vec<ProcurementEvent>, ProcurementError> {
    // ...
}

This signature is the domain core’s complete contract: it accepts a procurement command, consults storage through a repository port, and produces either a list of events or a domain error. Everything the domain can do is expressed in this type. The types are the architecture.

What We Have Built

The procurement domain core is now a collection of propositions proved by construction:

ComponentPropositionLevel
Amount, RequestId, ReasonValues satisfy domain invariants0
ProcurementRequest<State>State machine transitions are valid4
RequestRepository traitPersistence capability exists2
ProcurementErrorFailure modes are enumerated0
ProcurementCommand, ProcurementEventInteraction vocabulary is closed0
Impl blocks (Chapter 10)Capabilities are proved for concrete types3

The domain core has no infrastructure imports. It does not know about databases, HTTP frameworks, or message queues. It defines propositions and requires proofs (via trait bounds). The proofs arrive from outside — from infrastructure adapters — and the compiler verifies that every proof obligation is discharged.

What this chapter has not addressed is how to wire it: how to organise the crate structure, how to connect infrastructure to the domain core, how to choose between generic dispatch and dynamic dispatch, how to test the assembly. These are architectural questions, and they are the subject of the next chapter.

Architecture as Category

The previous chapter constructed a domain core: newtypes with smart constructors, a typestate lifecycle, trait ports, error types, and algebraic data flows. The result is a collection of propositions proved by construction — but it exists in isolation. A domain core that cannot be wired to a database, exposed through an API, or tested in isolation is an academic exercise. This chapter shows how the two-category model turns architectural patterns into categorical structure.

Several named patterns compete for this architectural space — hexagonal architecture, onion architecture, ports-and-adapters, Clean Architecture — and practitioners hold strong opinions about the differences between them. Before mapping the architecture onto the two-category model, it is worth being explicit about what these patterns share, where they diverge, and what the type-theoretic view has to say about the disagreements.

Hexagonal, Onion, Clean: A Disambiguation

Alistair Cockburn’s hexagonal architecture (2005) draws a single boundary between “inside” (application logic) and “outside” (adapters), with symmetric driving ports (incoming) and driven ports (outgoing). It makes no commitment to concentric layers within the inside — only that the inside defines ports and the outside provides adapters. Jeffrey Palermo’s onion architecture (2008) adds internal structure: concentric rings from domain model outward through domain services to application services, each depending only on the rings inside it. Robert C. Martin’s Clean Architecture (2012) refines this further into four rings — entities, use cases, interface adapters, frameworks & drivers — and articulates a “Dependency Rule” as the governing principle.

These patterns are not synonyms, but they share a common structural commitment: dependencies point inward, from concrete to abstract. The disagreements are about how many rings to draw and what vocabulary to use for each.

PatternCore InsightRing CountKey Vocabulary
Hexagonal (Cockburn)Inside/outside boundary, symmetric ports2Ports, adapters, driving, driven
Onion (Palermo)Concentric domain layers3–4Domain model, domain services, application services
Clean (Martin)Dependency Rule across four rings4Entities, use cases, interface adapters, frameworks
Type-theoretic (this book)F: 𝒯 → 𝒱 determines dependency directionFlexiblePropositions, proofs, boundary, functor

The type-theoretic view subsumes all three. The dependency direction is not a design rule to be memorised — it is a consequence of the categorical structure. Propositions (traits, type constraints, domain invariants) live in 𝒯. Proofs (impl blocks, concrete types, infrastructure adapters) live closer to 𝒱. The forgetful functor F: 𝒯 → 𝒱 flows from abstract to concrete, from proposition to proof to erased execution. Any architecture that reverses this direction — making the domain depend on infrastructure — is asking a proposition to depend on its own proof, which is incoherent.

This means the number of rings is a packaging decision, not a structural one. Two rings (hexagonal) or four rings (Clean) or five rings are all valid if the dependency direction is preserved. What matters is not the count but the invariant: propositions inward, proofs outward, and the functor flowing from 𝒯 to 𝒱. The rest of this chapter uses four rings because that granularity maps cleanly onto Rust’s level hierarchy, but the principles apply equally to a two-ring hexagonal layout or a three-ring onion.

Practitioners arriving from a Clean Architecture background will recognise the Dependency Rule as an instance of this functor direction. Those arriving from a hexagonal background will recognise the port/adapter split as the Level 2 / Level 3 boundary (traits vs impl blocks). The type-theoretic framing does not invalidate either tradition — it provides a formal justification for the structural commitment they share, and a diagnostic tool (the level hierarchy) for resolving the questions they leave open.

The Ring Topology

The architecture has four rings, each with a category-theoretic role:

RingContentsCategory-Theoretic RoleLevels
Domain CoreNewtypes, typestate, domain traits, error types, commands, eventsObjects and morphisms of the domain sub-category0, 2, 4
Application (Ports)Service traits, use-case orchestration, generic function signaturesFunctor interfaces — mappings between domain and infrastructure2
Infrastructure (Adapters)Database implementations, HTTP handlers, message queue adaptersNatural transformations — proofs that infrastructure satisfies port contracts3
Composition Rootmain.rs, concrete type wiring, configurationSite where F: 𝒯 → 𝒱 is finally applied — all generics resolvedAll collapse

The rings have a strict dependency direction: each ring may depend on the rings inside it, but never on the rings outside it. Domain Core depends on nothing. Application depends on Domain Core. Infrastructure depends on Domain Core and Application. Composition Root depends on everything.

This is not arbitrary. The domain core defines propositions (Level 2 traits, Level 0 types, Level 4 typestate). The application layer uses these propositions as bounds on generic functions — it works with types that satisfy domain contracts without knowing which concrete types will appear. The infrastructure layer proves the propositions — it provides concrete impl blocks for the domain traits. The composition root instantiates everything — it resolves all generic parameters to concrete types, the moment where F collapses the type-level structure into runtime execution.

The Application Layer as Port Definition

The application layer sits between the domain core and infrastructure. Its primary role is defining ports — trait-bounded function signatures that orchestrate domain logic without committing to infrastructure:

#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
impl RequestId { pub fn new(v: u64) -> Self { RequestId(v) } }
#[derive(Debug, Clone, PartialEq)]
pub struct Amount(f64);
impl Amount { pub fn new(v: f64) -> Result<Self, ProcurementError> {
    if v > 0.0 { Ok(Amount(v)) } else { Err(ProcurementError::InvalidAmount) }
}}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reason(String);
impl Reason { pub fn new(t: &str) -> Result<Self, ProcurementError> {
    if !t.is_empty() { Ok(Reason(t.to_string())) } else { Err(ProcurementError::EmptyReason) }
}}
#[derive(Debug)]
pub enum ProcurementError {
    InvalidAmount, EmptyReason, NotFound { id: String },
    StorageUnavailable { cause: String },
}
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}
impl std::error::Error for ProcurementError {}
#[derive(Debug, Clone)]
pub struct RequestSnapshot {
    pub id: RequestId, pub amount: Amount, pub reason: Reason, pub status: String,
}
pub trait RequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError>;
    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError>;
}
#[derive(Debug, Clone)]
pub enum ProcurementEvent {
    DraftCreated { id: RequestId, amount: Amount, reason: Reason },
}
pub fn create_draft(
    repo: &impl RequestRepository,
    id: RequestId,
    amount: Amount,
    reason: Reason,
) -> Result<ProcurementEvent, ProcurementError> {
    let snapshot = RequestSnapshot {
        id: id.clone(),
        amount: amount.clone(),
        reason: reason.clone(),
        status: "draft".to_string(),
    };
    repo.save(&snapshot)?;
    Ok(ProcurementEvent::DraftCreated { id, amount, reason })
}
}

The function create_draft is generic over impl RequestRepository. It states a proposition: “given any proof of RequestRepository, I can create a draft and produce an event.” The function does not know whether the repository is backed by PostgreSQL, an in-memory map, or a test fake. It operates at Level 2, working with the bound, not the concrete type.

Designing Port Traits

A port trait is a proposition about an infrastructure capability. The design of that proposition — which Level 2 mechanisms it uses — determines how easy the trait is to implement, test, and compose. Chapters 5 and 7 derive the theoretical distinctions; here we state the architectural defaults.

Associated types are the default for ports. A port trait almost always represents a functional dependency: one implementing type determines exactly one associated output. RequestRepository yields one Error type; an EventBus publishes one Event family. This is the functional-dependency pattern from Chapter 5 §5.4 — each implementor pins the associated type to a single concrete choice, and consumers work with the associated type abstractly. Type parameters on the trait (trait Repository<E>) would allow a single type to implement the trait multiple times with different error types, which is rarely the intent for a port. If a type genuinely needs to be a repository for multiple distinct entity families, that is usually better modelled as separate traits (RequestRepository, InvoiceRepository) rather than a single parameterised trait.

GATs are reserved for the lending and streaming cases. A port needs a GAT only when its associated type must borrow from &self or vary with a lifetime — the lending iterator pattern from Chapter 7 §7.2. In architectural terms, this arises when a port must yield references into its own internal state (a connection-pool cursor, a memory-mapped slice, a streaming row set). If the port’s methods return owned values or values that outlive the method call, a plain associated type suffices. Reaching for a GAT when an ordinary associated type would compile adds complexity to every implementor and every consumer for no structural gain.

Bound accumulation is a ring-topology signal. Chapter 5 §5.3 noted that heavy bound propagation — a function requiring R: RequestRepository + NotificationSender + AuditLogger + MetricsRecorder — can indicate that the function is doing too much. In the ring topology, this diagnosis sharpens: a function in the application layer that accumulates many port bounds is spanning multiple domain capabilities in a single orchestration step. Each bound is a hypothesis the function depends on; too many hypotheses in one proof suggests the proof should be decomposed. The fix is usually to split the function into smaller use-case functions, each with a narrow bound set, composed at a higher level. The minimum-bounds principle from Chapter 5 applies directly: state only the hypotheses you use, and if you use many, ask whether the proof is really one proof or several.

Supertraits for structural prerequisites, not convenience bundles. A supertrait bound (trait Repository: Send + Sync) is an implication: every proof of Repository must also be a proof of Send + Sync. This is appropriate when the supertrait is a genuine structural prerequisite — a repository that will be shared across async tasks must be thread-safe. It is inappropriate as a convenience mechanism to avoid writing bounds at call sites. Bundling Debug + Clone + Serialize as supertraits of a port trait forces every implementor to prove all three, even when the port’s contract has nothing to do with serialisation. The supertrait should express the minimal structural requirement of the port itself, not the accumulated needs of its current consumers.

Crate Topology as Proof Structure

In Rust, the ring boundaries can be encoded as Cargo workspace crates. The Cargo dependency graph makes boundary violations mechanically detectable:

procurement/
├── Cargo.toml              (workspace root)
├── procurement-domain/     (pure 𝒯: newtypes, traits, typestate, events)
│   ├── Cargo.toml          (no infrastructure dependencies)
│   └── src/lib.rs
├── procurement-app/        (Level 2: generic orchestration)
│   ├── Cargo.toml          (depends on: procurement-domain)
│   └── src/lib.rs
├── procurement-infra/      (Level 3: concrete impl blocks)
│   ├── Cargo.toml          (depends on: procurement-domain, procurement-app, sqlx, etc.)
│   └── src/lib.rs
└── procurement-bin/        (composition root)
    ├── Cargo.toml          (depends on: all of the above)
    └── src/main.rs

The critical rule: procurement-domain/Cargo.toml must have no infrastructure dependencies. If the domain crate imports sqlx, tokio, reqwest, or any infrastructure library, a ring boundary has been violated. The Cargo dependency graph makes this enforceable by inspection — and by CI policy if desired.

Each crate’s Cargo.toml encodes the dependency direction:

procurement-domain  →  (nothing)
procurement-app     →  procurement-domain
procurement-infra   →  procurement-domain, procurement-app, sqlx, tokio
procurement-bin     →  procurement-domain, procurement-app, procurement-infra, tokio

The arrows flow outward: from abstract to concrete, from proposition to proof. No arrow points inward. The domain core is a source in the dependency graph — it has in-degree zero.

When Rings Collapse

The four-crate workspace above is appropriate for a system with a rich domain, multiple infrastructure backends, and a team large enough to benefit from independent compilation boundaries. Not every system warrants this granularity. The principle — propositions inward, proofs outward — is invariant; the packaging should match the system’s complexity.

Module boundaries instead of crate boundaries. A single-crate system can encode the same ring structure using modules:

src/
├── domain/        (newtypes, traits, typestate, events)
│   └── mod.rs
├── app/           (generic orchestration, use-case functions)
│   └── mod.rs
├── infra/         (impl blocks, database adapters)
│   └── mod.rs
└── main.rs        (composition root)

The enforcement mechanism is weaker — Rust’s visibility modifiers (pub(crate), pub(super)) rather than Cargo’s dependency graph — but the structure is the same. A use crate::infra::* in domain/mod.rs is a ring violation detectable by code review, and enforceable by a lint or CI check if desired.

Two rings instead of four. For a small service with one or two ports, the application and infrastructure layers may contain so little code that separating them adds more navigational friction than it prevents. In that case, a two-ring structure — domain module and everything-else module — preserves the core invariant (domain depends on nothing) while avoiding empty abstraction layers.

No rings at all. A CLI tool that parses arguments, does some computation, and prints output may have no meaningful domain/infrastructure split. Forcing the ring topology onto a 200-line programme is over-engineering. The type-theoretic principles still apply at the value level — newtypes, smart constructors, Result propagation — but the architectural packaging adds no value.

The heuristic: separate crates when you need independent compilation, publication, or team ownership of the domain. Use modules when the system fits comfortably in a single crate. Use a flat file when the system fits comfortably in a single module. The functor direction is always the same; only the granularity of the boundary changes.

Visibility as Proof Scope

Rust’s visibility modifiers restrict where proofs are available. A pub(crate) type or method is visible within its crate but not outside it — the proof of its existence is scoped to the crate boundary. This is useful for infrastructure internals:

// In procurement-infra/src/lib.rs:
pub struct PostgresRequestRepository {
    pool: sqlx::PgPool,  // pub struct, but pool is private
}

impl PostgresRequestRepository {
    pub(crate) fn new(pool: sqlx::PgPool) -> Self {
        PostgresRequestRepository { pool }
    }
}

The pool field is private — no code outside the struct can access the database connection directly. The constructor is pub(crate) — only the infrastructure crate can create a PostgresRequestRepository. The domain core cannot construct it (it does not even know the type exists). Only the composition root, which depends on the infrastructure crate, can obtain one — and it does so through the infrastructure crate’s public API.

This is information hiding at the proof level. The domain core knows the proposition RequestRepository. The infrastructure crate knows the proof impl RequestRepository for PostgresRequestRepository. The composition root knows both and connects them. Each ring sees exactly the proofs it needs.

The dyn versus Generic Decision

Chapter 8 showed that dyn Trait is a partial projection through the boundary: the concrete type is erased, but the trait’s interface survives as a vtable. For architectural design, the choice between generic dispatch and dyn Trait is a choice about when proofs are resolved.

FormLevelWhat Survives FRuntime Cost
fn f<R: RequestRepository>(r: &R)Level 2Full monomorphisationZero
fn f(r: &impl RequestRepository)Level 2 (sugar)SameZero
fn f(r: &dyn RequestRepository)Partial 𝒱Interface; identity erasedvtable indirection
Box<dyn RequestRepository>𝒱Only vtable pointerHeap + indirection

A generic bound (R: RequestRepository) is a compile-time proof obligation: the caller must provide a concrete type that satisfies the trait. The compiler resolves this at monomorphisation, producing specialised code for each concrete type. The proof is fully discharged in 𝒯 and completely erased in 𝒱.

A dyn Trait is a deferred proof: “I cannot prove at compile time which implementation will be provided.” The vtable carries enough information to dispatch method calls at runtime, but the concrete type’s identity is lost. This is appropriate when:

  • Runtime configuration: the repository implementation is determined by a configuration file at startup.
  • Plugin systems: adapters are loaded dynamically or selected at runtime.
  • Heterogeneous collections: you need a Vec<Box<dyn EventListener>> holding different listener implementations.

It is inappropriate when the concrete type is known at the composition root and fixed for the lifetime of the program. In that case, generic dispatch gives you zero-cost abstraction — the proof is resolved at compile time and erased without a trace.

For the procurement system, the repository implementation is typically fixed: either PostgreSQL in production or an in-memory implementation in tests. Generic dispatch is appropriate:

#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
#[derive(Debug, Clone)]
pub struct RequestSnapshot { pub id: RequestId, pub status: String }
#[derive(Debug)]
pub struct ProcurementError;
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}
impl std::error::Error for ProcurementError {}
pub trait RequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError>;
    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError>;
}
pub struct ProcurementService<R: RequestRepository> {
    repo: R,
}

impl<R: RequestRepository> ProcurementService<R> {
    pub fn new(repo: R) -> Self {
        ProcurementService { repo }
    }

    pub fn find_request(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError> {
        self.repo.find(id)
    }
}
}

The service is generic over R. At the composition root, R is resolved to a concrete type. The monomorphiser produces ProcurementService<PostgresRequestRepository> in production and ProcurementService<InMemoryRequestRepository> in tests. No vtable, no heap allocation, no indirection. The architectural abstraction is zero-cost.

The dyn alternative becomes appropriate if the system must support switching repository implementations at runtime — for instance, a multi-tenant system where different tenants use different storage backends. In that case, the deferred proof is justified: the runtime must carry enough information (the vtable) to dispatch correctly.

dyn at the Framework Edge

In practice, Rust web frameworks often push towards Arc<dyn Trait> at the handler boundary. Frameworks like axum use type-erased shared state — a handler extracts State<Arc<dyn RequestRepository>> because the framework’s router dispatches to handlers dynamically, and all handlers sharing a router must agree on a single state type. This is a genuinely dynamic context: the framework’s routing model requires type erasure at the point where it stores and distributes shared state.

The type-theoretic position is not that dyn is always wrong — it is that dyn should be justified by a genuinely dynamic context, and confined to the boundary where that dynamism exists. The framework edge is such a boundary. The mistake is propagating dyn inward from the framework edge through every service and helper, paying the cost of deferred proof in code that has no need for it.

The principle: accept dyn at the framework boundary where the framework requires it; keep service and domain code generic over trait bounds. The framework boundary is a composition root — it is where the functor is applied, so the concrete/erased distinction is expected there. Inside the domain and application layers, generic dispatch preserves zero-cost abstraction and full compile-time proof checking.

The Composition Root

The composition root is where F: 𝒯 → 𝒱 is finally applied. All generic parameters are resolved. All trait bounds are discharged with concrete types. All type-level structure collapses into runtime execution.

// procurement-bin/src/main.rs
use procurement_domain::*;
use procurement_app::ProcurementService;
use procurement_infra::PostgresRequestRepository;

#[tokio::main]
async fn main() {
    let pool = sqlx::PgPool::connect("postgres://...").await.unwrap();
    let repo = PostgresRequestRepository::new(pool);
    let service = ProcurementService::new(repo);

    // service is ProcurementService<PostgresRequestRepository>
    // All generics resolved. All proofs discharged. Pure V from here.
}

The composition root is intentionally the thickest, least abstract, most concrete part of the system. It names specific types: PostgresRequestRepository, not impl RequestRepository. It calls constructors with configuration values. It wires everything together.

This is not a design flaw — it is the design working correctly. The domain core is abstract (Level 2–4, pure 𝒯). The infrastructure layer provides proofs (Level 3, impl blocks). The composition root instantiates (Level 0, concrete types). The functor F acts at this point, collapsing the entire type-level architecture into a running program. After this point, no compile-time proof machinery is operating. The binary is pure 𝒱.

The composition root is also the narrowest part of the system in terms of reuse: it is specific to one deployment configuration. A test harness is an alternative composition root, wiring different concrete types (in-memory repositories, stub services) against the same generic domain and application layers.

Dependency Inversion without a Container

Practitioners arriving from Java, C#, or other managed-runtime ecosystems may expect a dependency injection (DI) container — a framework like Spring, Autofac, or Guice that discovers implementations at startup, resolves dependency graphs, and wires everything together at runtime. The composition root above performs the same logical function, but the mechanism is fundamentally different.

A DI container resolves dependencies at runtime. It inspects type metadata (reflection, attributes, module scanning), matches interfaces to implementations, and constructs object graphs dynamically. This is a deferred proof: the container discovers at startup whether every required interface has an implementation. If a binding is missing, the error surfaces at runtime — often as a startup exception rather than a compile-time error.

Rust’s generic bounds achieve dependency inversion at compile time. There is no container; the compiler is the container. When ProcurementService<R: RequestRepository> is instantiated as ProcurementService<PostgresRequestRepository>, the compiler verifies that PostgresRequestRepository satisfies RequestRepository before emitting any code. If the proof is missing — no impl RequestRepository for PostgresRequestRepository — the programme does not compile. The wiring in main.rs is manual and explicit, but the proof obligation is discharged in 𝒯, not deferred to 𝒱.

This is not an incidental implementation difference. It is the zero-cost abstraction property applied to architecture itself: the dependency graph is resolved at compile time and erased at runtime. The binary carries no reflection metadata, no container state, no startup resolution phase. The cost of abstraction is zero.

Cross-Cutting Concerns

The ring topology cleanly separates domain, application, and infrastructure — but practitioners will immediately notice that some concerns do not fit neatly into any ring. Logging, tracing, and metrics cut across all layers. Where do they belong?

In type-theoretic terms, cross-cutting concerns are observations of proof execution, not propositions or proofs themselves. They do not define domain invariants (propositions) or implement domain contracts (proofs) — they observe and record the act of proof discharge as it happens at runtime.

Rust’s tracing ecosystem already follows this structure. The tracing crate provides a facade: domain and application code emit spans and events (tracing::info!, #[instrument]), which are essentially propositions — “this operation occurred with these parameters.” The concrete subscriber (a JSON logger, an OpenTelemetry exporter, a test subscriber that captures events for assertions) is an infrastructure proof, wired at the composition root. The domain crate depends on tracing — a lightweight, zero-cost facade with no infrastructure opinions — not on any specific subscriber implementation.

This mirrors the log crate’s design and generalises it. The facade is a Level 2 trait (a proposition about observability capability). The concrete logger or subscriber is a Level 3 proof. The composition root selects which proof to install. The domain core remains free of infrastructure dependencies, and the ring invariant is preserved.

Testing as Alternative Proof

A test double — a mock, stub, or fake — is an alternative proof of a port’s proposition. The trait RequestRepository states a proposition: “there exists a mechanism that can persist and retrieve procurement requests.” A production implementation (PostgresRequestRepository, introduced in the composition root at §10.4) proves this proposition with a database. A test implementation proves the same proposition with a HashMap:

use std::collections::HashMap;
use std::cell::RefCell;
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
impl RequestId {
    pub fn new(id: u64) -> Self { RequestId(id) }
}

#[derive(Debug, Clone)]
pub struct RequestSnapshot {
    pub id: RequestId,
    pub status: String,
}

#[derive(Debug)]
pub struct ProcurementError;
impl fmt::Display for ProcurementError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "error") }
}
impl std::error::Error for ProcurementError {}

pub trait RequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError>;
    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError>;
}

// --- The test proof: InMemoryRequestRepository ---

pub struct InMemoryRequestRepository {
    store: RefCell<HashMap<u64, RequestSnapshot>>,
}

impl InMemoryRequestRepository {
    pub fn new() -> Self {
        InMemoryRequestRepository {
            store: RefCell::new(HashMap::new()),
        }
    }
}

impl RequestRepository for InMemoryRequestRepository {
    fn save(&self, snapshot: &RequestSnapshot) -> Result<(), ProcurementError> {
        self.store.borrow_mut().insert(snapshot.id.0, snapshot.clone());
        Ok(())
    }

    fn find(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError> {
        Ok(self.store.borrow().get(&id.0).cloned())
    }
}

// --- The service, generic over any proof of RequestRepository ---

pub struct ProcurementService<R: RequestRepository> {
    repo: R,
}

impl<R: RequestRepository> ProcurementService<R> {
    pub fn new(repo: R) -> Self {
        ProcurementService { repo }
    }

    pub fn find_request(
        &self,
        id: &RequestId,
    ) -> Result<Option<RequestSnapshot>, ProcurementError> {
        self.repo.find(id)
    }

    pub fn create_draft(
        &self,
        id: RequestId,
        reason: &str,
    ) -> Result<(), ProcurementError> {
        let snapshot = RequestSnapshot {
            id,
            status: "draft".to_string(),
        };
        self.repo.save(&snapshot)
    }
}

// --- A test using the alternative proof ---

fn test_create_and_retrieve() {
    let repo = InMemoryRequestRepository::new();
    let service = ProcurementService::new(repo);

    let id = RequestId::new(42);
    service.create_draft(id.clone(), "Office supplies").unwrap();

    let found = service.find_request(&id).unwrap();
    assert!(found.is_some());
    assert_eq!(found.unwrap().status, "draft");
}

fn main() {
    test_create_and_retrieve();
}

The test wires ProcurementService with InMemoryRequestRepository — the alternative proof. In production, the same service is wired with PostgresRequestRepository (§10.4). Both implementations satisfy RequestRepository; the compiler verifies each is a valid proof of the same proposition. The domain core and application layer cannot distinguish between them — they operate generically over R: RequestRepository, and the type system guarantees that any valid proof will work.

This is the practical content of “testing as alternative proof.” The test does not verify the service by inspecting its internals or mocking its dependencies. It provides a different proof of the same port contract and runs the service against it. If the service works with one valid proof, and the production implementation is also a valid proof (verified by the compiler), the architectural guarantee holds.

Fakes versus Mocks

The InMemoryRequestRepository is a fake: a genuine alternative implementation with real behaviour. It stores data, retrieves data, and maintains the save-then-find contract. It is a complete proof of the RequestRepository proposition.

A mock is different. A mock records interactions and asserts on them — “did the code call save with these arguments?” A mock is a partial proof, valid only under specific interaction assumptions. It does not prove that RequestRepository can persist and retrieve data; it proves that certain method calls occurred in a certain order.

In type-theoretic terms: a fake is a valid proof that lives in a different model (in-memory rather than PostgreSQL, but still a model of persistence). A mock is a proof about interaction patterns, not about the proposition itself. Both are useful; the distinction matters for understanding what your tests actually prove.

Parametricity and Property Testing

Chapter 4 showed that parametricity — Reynolds’ abstraction theorem — constrains what a generic function can do. A function fn f<T>(x: T) -> T must be the identity. A function fn sort<T: Ord>(xs: Vec<T>) -> Vec<T> must produce a permutation of the input (it cannot fabricate new T values).

Parametricity is also what justifies test doubles at a formal level. ProcurementService<R: RequestRepository> is parametric over R. The service cannot inspect which concrete repository it holds — it can only call the methods that RequestRepository exposes. This means: if two implementations of RequestRepository behave identically on the same method calls, the service must behave identically with both. The parametricity theorem guarantees this. A test that passes with InMemoryRequestRepository proves a property of the service that holds for any valid implementation, including PostgresRequestRepository — provided the two implementations agree on the observable behaviour of the trait methods.

This insight generates property tests. Consider any function with a constrained generic signature:

#![allow(unused)]
fn main() {
fn sort_and_dedup<T: Ord + Clone>(mut xs: Vec<T>) -> Vec<T> {
    xs.sort();
    xs.dedup();
    xs
}
}

Parametricity tells us, before looking at the implementation:

  • The output is a sub-sequence of the input (the function cannot fabricate new T values — it has no constructor for T).
  • The output is sorted (the Ord bound is the only ordering available).
  • The output contains no duplicates.

Each constraint derived from the type signature becomes a testable property:

fn sort_and_dedup<T: Ord + Clone>(mut xs: Vec<T>) -> Vec<T> {
    xs.sort();
    xs.dedup();
    xs
}
fn test_output_is_sorted() {
    let input = vec![3, 1, 4, 1, 5, 9, 2, 6];
    let output = sort_and_dedup(input);
    for pair in output.windows(2) {
        assert!(pair[0] <= pair[1]);
    }
}

fn test_output_is_subset_of_input() {
    let input = vec![3, 1, 4, 1, 5, 9, 2, 6];
    let input_clone = input.clone();
    let output = sort_and_dedup(input);
    for item in &output {
        assert!(input_clone.contains(item));
    }
}

fn test_no_duplicates() {
    let input = vec![3, 1, 4, 1, 5, 9, 2, 6];
    let output = sort_and_dedup(input);
    let len_before = output.len();
    let mut deduped = output.clone();
    deduped.dedup();
    assert_eq!(len_before, deduped.len());
}

fn main() {
    test_output_is_sorted();
    test_output_is_subset_of_input();
    test_no_duplicates();
}

The same principle applies to the procurement service. The signature fn find_request(&self, id: &RequestId) -> Result<Option<RequestSnapshot>, ProcurementError> on ProcurementService<R: RequestRepository> tells us: the service can only return what the repository provides or a ProcurementError. It cannot fabricate request data. A property test can verify this — save a snapshot, retrieve it, confirm the result matches — and parametricity guarantees the property holds regardless of which R is supplied.

Programming Style as Altitude: A Decision Guide

Chapter 8 introduced the idea of programming style as altitude — the choice of how high in the level hierarchy to operate for a given piece of code. The ring topology and the level hierarchy together provide a practical decision framework for the choices practitioners face daily. The following table summarises the key decision points:

Decision PointLow Altitude (concrete, 𝒱-oriented)High Altitude (abstract, 𝒯-oriented)When to Choose High
Data representationString, u64, boolRequestId(Uuid), Amount(f64)Always for domain values — encoding propositions in 𝒯 is the book’s central thesis
Function parameterPostgresPool, a concrete typeimpl Repository / R: RepositoryWhen more than one implementation exists or is anticipated (production + test counts)
Error handling.unwrap(), String errorsDomain error enum, Result propagation with ?Always in domain and application layers; .unwrap() is acceptable only in composition roots and tests where failure is truly unrecoverable
Dispatchdyn Trait, vtable indirectionGeneric bounds, monomorphisationWhen the concrete type is known at compile time and fixed for the programme’s lifetime
State machinestatus: String + runtime matchTypestate with PhantomDataWhen invalid state transitions are a real risk and the state graph is small enough to enumerate
ArchitectureFlat module, no ringsCrate workspace with ring boundariesWhen the domain is rich enough that independent compilation or team ownership of the domain core adds value

The altitude is not a quality judgement — low altitude is not “worse” than high altitude. A composition root should be low altitude: it names concrete types, calls constructors, and wires everything together. A domain core should be high altitude: it defines propositions that the rest of the system must satisfy. The anti-patterns in the next section are not cases of “too low” — they are cases of mismatched altitude, where code operates at a lower level than its role in the architecture demands.

The Anti-Pattern Catalogue

The five levels and the ring topology provide a diagnostic vocabulary for common architectural failures. Each anti-pattern below is a violation of a specific type-theoretic principle; each fix lifts a proposition from 𝒱 to 𝒯.

Stringly-Typed Domain

Symptom: status: String, id: u64, currency: &str — domain values represented as primitive types.

Diagnosis: Propositions not encoded. The distinction between a request ID and an invoice ID, or between “draft” and “submitted”, exists only in programmer intent — in 𝒱 rather than 𝒯. The compiler cannot prevent mixing a request ID with an invoice ID because they are both u64.

Fix: Newtypes with smart constructors (Level 0, Chapter 9 §9.2). RequestId(Uuid), Amount(f64), Reason(String) — each carries a domain invariant proved at construction time.

Infrastructure Leaking into Domain

Symptom: Domain types import sqlx::Row, serde_json::Value, or reqwest::Error. Repository methods return database-specific types.

Diagnosis: Infrastructure proofs crossing the ring boundary. The domain core, which should contain only propositions, has become entangled with specific proof implementations. This couples the domain to infrastructure and prevents independent testing.

Fix: Adapter mapping at the ring boundary (Chapter 10 §10.1). Domain types define their own vocabulary (RequestSnapshot, ProcurementError). Infrastructure adapters convert between infrastructure types and domain types. The From trait serves as the proof transformation mechanism.

Incoherent Service Traits

Symptom: A trait with fifteen methods spanning multiple concerns — persistence, validation, notification, logging. Impossible to implement a test double without implementing every method.

Diagnosis: Incoherent proposition. The trait bundles multiple unrelated capabilities into a single predicate. In the constraint lattice (Chapter 5), this is a conjunction so large that no useful type can satisfy it without being a god object.

Fix: Trait segregation into single-capability ports. One trait per domain capability: RequestRepository for persistence, NotificationSender for notifications. Each trait is a focused proposition that can be independently proved and independently tested.

Gratuitous Cloning at Boundaries

Symptom: .clone() on every value crossing a layer boundary. Functions take owned values when they only need references.

Diagnosis: Port designed for interface-orientation (method calls with owned parameters) rather than data-orientation (borrowing and returning). The clone is the cost of misaligned ownership at the boundary.

Fix: Design ports with Rust’s ownership model in mind. Accept references when the port does not need ownership. Return owned values only when ownership transfer is the intent. Consider accepting impl Into<String> or &str rather than String when the port may or may not need to allocate.

unwrap() in Domain Logic

Symptom: Domain functions calling .unwrap() on Result or Option values, converting type-level error handling into runtime panics.

Diagnosis: Proof obligations discharged in 𝒱 instead of 𝒯. The Result type encodes a disjunction (success ∨ failure), and unwrap is an unsound disjunction elimination — it asserts “the right disjunct cannot occur” without proof. A panic is the runtime consequence of an unproved assertion.

Fix: Propagate Result with ? (Chapter 9 §9.5). Define domain error types that name every failure mode. The ? operator is sound disjunction elimination — it handles the right disjunct by propagating it. The caller, not the callee, decides how to handle failure.

Arc<dyn Trait> by Default

Symptom: Every port is Arc<dyn Trait>, every service holds Arc<dyn Repository>, heap allocation and vtable indirection throughout the hot path.

Diagnosis: Deferred proof where static dispatch was possible. dyn Trait is appropriate when the concrete type is genuinely unknown at compile time. When the concrete type is fixed at the composition root and never changes, dyn pays the cost of runtime dispatch for no benefit.

Fix: Generic bounds (Chapter 10 §10.3). Use <R: RequestRepository> on the service struct. The composition root resolves R to the concrete type. Monomorphisation erases the generic parameter, producing zero-cost dispatch. Reserve dyn Trait for genuinely dynamic cases — runtime configuration, plugin systems, heterogeneous collections.

Summary Table

Anti-PatternType-Theoretic DiagnosisFixReference
Stringly-typed domainPropositions unencoded in 𝒯Newtypes + smart constructors§9.2
Infrastructure leaking into domainProofs crossing ring boundaryAdapter mapping§10.1
Incoherent service traitIncoherent proposition (over-large conjunction)Trait segregation§9.3
Gratuitous cloningOwnership misaligned at boundaryBorrow-aware port design§10.3
unwrap() in domain logicUnsound disjunction eliminationResult propagation§9.5
Arc<dyn Trait> by defaultDeferred proof without justificationGeneric bounds§10.3

The Title Discharged

The book’s title makes a claim: types are propositions. The preceding chapters derived this claim theoretically — through the Curry-Howard correspondence, through the five levels of Rust’s type hierarchy, through the forgetful functor and the boundary. This chapter and the last have discharged the claim practically.

A well-architected Rust system is a proof system. The domain core defines propositions. Infrastructure provides proofs. The composition root applies the forgetful functor — collapsing 𝒯 into 𝒱, erasing the type-level structure, producing a running binary that carries no trace of the proofs that validated it. Tests are alternative proofs. Anti-patterns are proof system failures.

This is not metaphor. When the compiler rejects a programme because a trait bound is not satisfied, it has found a gap in the proof — a proposition that no impl block discharges. When a typestate machine prevents an invalid state transition, it has enforced a theorem — a structural property that no runtime check could match for reliability. When a newtype with a smart constructor guarantees that an amount is positive, it has proved an invariant — once, at construction time, and never again.

Rust’s type system can prove a great deal about a domain at compile time, but it cannot express everything. The propositions you cannot yet prove — full refinement types, dependent types, effect annotations — map precisely onto the territories the next chapter explores.

Beyond Rust

Rust occupies a specific position in the space of possible type systems. The preceding chapters have mapped that position from within — ascending through the five levels of 𝒯, examining the boundary’s behaviour, tracing the forgetful functor’s action on each kind of type-level structure. The previous two chapters showed what those five levels can build in practice: domain models whose invariants are proved by construction and architectures whose ring boundaries are enforced by the type system. This chapter maps Rust’s position from without, placing it in the broader landscape of type systems and exploring the territories it does not enter.

The question is not “what is Rust missing?” Languages are not ranked on a linear scale of type-system sophistication. Rust’s position is a design choice — a principled selection of features determined by the boundary’s requirements. The question is: what does each region of the landscape offer, what does it cost, and why does Rust’s boundary rule certain regions out?

The Extended Level Hierarchy

The five levels introduced in this book (0 through 4) cover Rust’s type-level expressiveness. But the space of type systems extends in both directions. Below Level 0 lie mechanisms that Rust does not have. Above Level 4 lie mechanisms that Rust cannot reach. The full hierarchy, with representative languages, places Rust in context.

Levels Below Zero: Effects and Linearity

Level −1: Effect Types. Some type systems track not just what a function takes and returns, but what effects it may perform: mutation, I/O, exceptions, non-determinism. An effect type is a proposition about a function’s side effects, checked at compile time.

Rust does not have effect types. A function signature fn read_file(path: &str) -> String does not indicate that it performs I/O. A function fn sort(data: &mut [i32]) does not indicate that it mutates through a mutable reference in any way the effect-type literature would recognise — the mutation is encoded in the borrow system, not in an effect annotation.

Languages with effect systems include:

Koka (Microsoft Research) tracks effects algebraically. Every function’s type includes an effect row — a list of effects the function may perform:

// Koka: effect-annotated function type
fun read-file(path : string) : <io,exn> string

The effects <io,exn> are part of the type. A pure function has the empty effect row <>. The type checker verifies that effects are handled — you cannot call an effectful function from a pure context without an effect handler. Effect handlers are first-class: they can be defined, composed, and passed as arguments.

Effekt (University of Tübingen) takes a similar approach with second-class capabilities. Effects are tied to capability objects that must be in scope for the effect to be performed. This prevents effects from leaking through closures or being captured in data structures.

Haskell approximates effect types through the IO monad. A function of type IO a may perform I/O; a function of type a may not. The IO monad is not a true effect system — it is an encoding of effects within ordinary types — but it achieves a similar separation:

-- Haskell: IO as an effect marker
readFile :: FilePath -> IO String
pure     :: a -> IO a        -- lift a pure value into IO

The IO type acts as a boundary within Haskell’s type system: pure code cannot call IO functions without being in the IO monad. This is a coarse effect annotation — IO lumps all effects together — but it captures the essential idea: the type tells you whether a function is effectful.

Rust’s alternative to effect types is pragmatic: effects are encoded in return types (Result for failure, Option for absence) or in the type system’s structure (&mut for mutation). The borrow checker is, in a sense, a linearity-based effect system for memory effects. But general effects — I/O, non-determinism, exceptions — are not tracked in the type system. This is a boundary decision: tracking effects would require the boundary to translate effect annotations into runtime effect-handling code, adding complexity to both 𝒯 and the boundary.

Level −2: Uniqueness and Session Types. At a deeper level, some type systems track not just effects but the communication protocols between concurrent processes.

Session types describe the protocol of a communication channel as a type. A channel of type Send<i32, Recv<String, End>> must first send an i32, then receive a String, then close. The type checker verifies that both endpoints follow the protocol. Violating the protocol — sending when the type says to receive — is a type error.

Session types are related to linear types: a channel must be used exactly once at each protocol step, ensuring that messages are not duplicated or lost. The Rust crate ecosystem has experimental implementations, but session types are not part of Rust’s core type system.

Rust’s ownership system is a form of affine typing: values can be used at most once (moved). Full linear types would require that values be used exactly once — you cannot simply drop a value; you must explicitly consume it. Linear Haskell (-XLinearTypes) adds linear function arrows — functions that must use their argument exactly once — while the rest of the language remains unrestricted. Granule goes further, making linearity pervasive. Both explore the territory of requiring that resources be consumed rather than abandoned.

Levels Above Four: Dependent Types and Beyond

Level 5: Full Dependent Types. In a fully dependently typed language, arbitrary values can appear in types. The boundary between 𝒯 and 𝒱 dissolves: types can compute with runtime values, and runtime code can manipulate types as data.

Idris and Agda are the primary examples. In Idris:

-- Idris: a vector type indexed by its length
data Vect : Nat -> Type -> Type where
    Nil  : Vect 0 a
    (::) : a -> Vect n a -> Vect (n + 1) a

The type Vect 3 Int is different from Vect 5 Int — the length, a natural number, is part of the type. The type system tracks lengths through every operation:

-- Idris: append preserves length information
append : Vect n a -> Vect m a -> Vect (n + m) a
append Nil       ys = ys
append (x :: xs) ys = x :: append xs ys

The compiler verifies at compile time that appending a vector of length n and a vector of length m produces a vector of length n + m. The arithmetic happens at the type level. This is the third axis of the lambda cube fully activated: types depending on terms without restriction.

What does this mean for the boundary? In Idris, the boundary becomes programmable — the programmer can annotate which proofs are erased and which are retained at runtime (as described in Chapter 8). In Agda, which targets verification rather than execution, the boundary is even less defined: programs are primarily objects of proof, and extraction to executable code is a secondary concern.

The cost of full dependent types is complexity. Type checking becomes undecidable in general — the type checker may need to evaluate arbitrary computations to determine whether two types are equal. Idris and Agda manage this through totality checking (requiring that all functions terminate) and universe levels (stratifying the type hierarchy to prevent paradoxes). These mechanisms add significant cognitive overhead for the programmer.

Rust’s const generics are a pinhole into Level 5 — they allow scalar values in types, but not arbitrary computations. The restriction exists because Rust’s boundary demands that all type-level computation complete at compile time. Full dependent types would require the boundary to reason about runtime values during type checking, which would either make type checking undecidable or require a totality checker that Rust does not have.

Level 6: Proof Terms as Runtime Values. In Coq and Lean, proof terms are first-class values. An impl block equivalent is not erased — it exists at runtime as a data structure that can be inspected, stored, and computed with.

-- Coq: a proof term is a value
Definition proof_comm : forall n m : nat, n + m = m + n.
Proof. intros. omega. Qed.

The proof proof_comm is a value of type forall n m : nat, n + m = m + n. It can be passed to functions, stored in data structures, and used to justify subsequent proofs. When Coq code is extracted to OCaml or Haskell, proofs in the Prop universe are erased (they are logically irrelevant) and proofs in the Set universe are retained (they carry computational content). But within Coq’s type system, all proofs are values.

This is the full Curry-Howard correspondence without truncation. Where Rust’s boundary erases proofs after verification, Coq’s proofs persist as runtime entities. The cost is the overhead of carrying proof data at runtime. The benefit is that proofs can be computed with — a function can examine its proof obligations, construct proofs dynamically, and select different implementations based on the structure of the proof.

Rust’s proof erasure, by contrast, means that all proof selection happens at compile time. The choice of which impl Ord to use is resolved before the program runs. This is less expressive — you cannot select proofs dynamically — but it is zero-cost, which is the boundary’s fundamental requirement.

Level 7: Universe Polymorphism. In Agda and Coq, types themselves have types (called universes or sorts). Type has type Type₁, which has type Type₂, and so on. Universe polymorphism allows functions and data types to be parameterised by their universe level — quantifying not just over types, but over the level of the type hierarchy.

-- Agda: universe-polymorphic identity
id : {l : Level} → {A : Set l} → A → A
id x = x

The identity function works for values of types at any universe level. This prevents the need to duplicate definitions across universe levels (an analogue of Rust’s Level 0 repetition problem, but one level of abstraction higher).

Universe polymorphism is necessary to avoid logical paradoxes (Russell’s paradox) in dependently typed systems. It is irrelevant to Rust because Rust does not have types-of-types in the relevant sense: Vec is a type constructor (Type → Type), but there is no “type of all type constructors” that would require universe stratification.

Languages in the Lambda Cube

The lambda cube provides a map of type system capabilities. Here is where major languages sit:

Languageλ→Axis 1 (terms←types)Axis 2 (types←types)Axis 3 (types←terms)Position
CYesNo (pre-C11)NoNoλ→
JavaYesYes (generics)LimitedNoNear λ2
HaskellYesYesYes (type families)Noλω + extensions
RustYesYesYesPinhole (const generics)λω + pinhole
IdrisYesYesYesYesCC (extended)
AgdaYesYesYesYesCC (extended)
CoqYesYesYesYesCC (extended)

The bottom row of the cube (no dependent types) is where all mainstream production languages live. The top row (full dependent types) is occupied by proof assistants and research languages. Rust sits at the most expressive position on the bottom row — λω, with polymorphism and type operators — and reaches upward through const generics into the dependent-type dimension without committing to it.

Haskell’s Extensions

Haskell is an instructive comparison because it occupies a similar position to Rust in the cube (λω) but extends in different directions:

Type families give Haskell type-level computation. A type family is a function from types to types, evaluated at compile time:

-- Haskell: type-level computation
type family Element (container :: Type) :: Type where
    Element [a]       = a
    Element (Set a)   = a
    Element (Map k v) = (k, v)

This is similar to Rust’s associated types, but more general — type families can be open (extensible by later modules) or closed (fixed at definition), and they can perform pattern matching on types.

GADTs (Generalised Algebraic Data Types) allow constructors to refine the type they produce:

-- Haskell: GADT with refined return types
data Expr a where
    Lit  :: Int -> Expr Int
    Add  :: Expr Int -> Expr Int -> Expr Int
    IsZero :: Expr Int -> Expr Bool

The constructor Lit produces Expr Int (not Expr a). Pattern matching on Lit refines the type variable: inside the pattern, the compiler knows a = Int. This is a form of dependent typing — the constructor’s value influences the type — and it is available in Haskell but not in Rust’s standard enum system.

DataKinds promote data types to the kind level — values become types, types become kinds. This allows type-level natural numbers, type-level booleans, and other type-level data without full dependent types.

These extensions push Haskell further up the lambda cube than Rust, approaching dependent types from below. The cost is complexity: Haskell’s type system with all extensions enabled is significantly harder to learn and reason about than Rust’s. The benefit is expressiveness: Haskell can express invariants (like well-typed expression trees) that Rust cannot.

The Diagonal

There is a notable diagonal in the cube: languages tend to advance along all three axes together or not at all. C has none of the three. Rust has two fully and one partially. Idris, Agda, and Coq have all three. Few languages occupy the off-diagonal positions (dependent types without polymorphism, or polymorphism without type operators). This is not coincidental — the axes interact, and the features that make one axis useful tend to require the others.

What Dependent Types Make Possible

The most significant territory beyond Rust is full dependent types. Understanding what they enable — and what they cost — clarifies Rust’s design position.

Verified Data Structures

With dependent types, data structure invariants become part of the type:

-- Idris: a sorted list type
data SortedList : (a : Type) -> Ord a => Type where
    SNil  : SortedList a
    SCons : (x : a) -> (rest : SortedList a) ->
            {auto prf : So (maybe True (\y => x <= y) (head' rest))} ->
            SortedList a

The type SortedList a is not just “a list of a values” — it is “a list of a values that is sorted.” The sortedness invariant is in the type. You cannot construct a SortedList that is not sorted, because the constructor requires a proof that the new element is less than or equal to the existing head.

In Rust, sortedness is a runtime property — you call sort() and trust that the implementation is correct. The type system does not track whether a Vec is sorted. You can wrap it in a newtype and provide only methods that preserve sortedness, but the compiler does not verify the invariant — the programmer must ensure it manually within the impl block.

Correct-by-Construction Programs

Dependent types enable programs that are proven correct as a consequence of type checking:

-- Idris: matrix multiplication with dimension checking
multiply : Matrix m n -> Matrix n p -> Matrix m p

The types guarantee that the inner dimensions match (n in both matrices). A call with mismatched dimensions is a type error, not a runtime panic. The compiler verifies dimensional correctness as part of type checking.

In Rust, you could encode matrix dimensions with const generics:

#![allow(unused)]
fn main() {
struct Matrix<const M: usize, const N: usize> {
    data: [[f64; N]; M],
}

fn multiply<const M: usize, const N: usize, const P: usize>(
    a: &Matrix<M, N>,
    b: &Matrix<N, P>,
) -> Matrix<M, P> {
    let mut result = Matrix { data: [[0.0; P]; M] };
    for i in 0..M {
        for j in 0..P {
            for k in 0..N {
                result.data[i][j] += a.data[i][k] * b.data[k][j];
            }
        }
    }
    result
}
}

This works — the const generic N appears in both matrix types, ensuring dimensional agreement. Rust’s pinhole into dependent types is enough for this case. But it breaks down for dimensions that are not known at compile time — if M, N, and P come from user input, const generics cannot help. Full dependent types can track dimensions through runtime computation; Rust’s const generics require the values to be compile-time constants.

The Cost of Dependent Types

Full dependent types are powerful, but they impose costs that explain why Rust does not have them:

Undecidable type checking. Determining whether two dependent types are equal may require evaluating arbitrary computations. If those computations do not terminate, type checking does not terminate. Idris and Agda manage this by requiring totality (all functions must terminate), which is itself a significant restriction.

Cognitive overhead. Writing dependently typed programs requires the programmer to think simultaneously about code and proofs. A function that sorts a list must not only sort it but prove it is sorted. This doubles the specification burden for every operation.

Boundary complexity. If types depend on runtime values, the boundary between compile time and runtime becomes harder to draw. Idris addresses this with explicit erasure annotations. Agda sidesteps it by targeting verification rather than execution. Rust’s clean boundary — all type-level computation completes at compile time — would be impossible with unrestricted dependent types.

Compilation speed. Type checking in dependently typed languages can be slow, because it involves evaluating type-level programs. Rust’s type checking is already sometimes criticised for compilation speed; adding type-level evaluation would compound the problem.

These costs are not theoretical. They are practical obstacles that limit the adoption of dependently typed languages for production systems programming. Rust’s design avoids them by staying on the bottom row of the lambda cube, accepting the expressiveness limitation in exchange for a predictable, fast, zero-cost boundary.

Effect Systems: The Other Axis

Effect types represent an axis of type system design orthogonal to the lambda cube. While the cube tracks dependencies between terms and types, effect systems track what a computation does beyond producing a value.

Algebraic Effects

Algebraic effects, as implemented in Koka and Effekt, decompose effects into operations and handlers:

// Koka: declaring and handling an effect
effect ask<a>
  ctl ask() : a

fun greeting() : ask<string> string
  val name = ask()
  "Hello, " ++ name

fun main()
  with handler
    ctl ask() -> resume("World")
  greeting().println

The function greeting has the effect ask<string> — it may perform the ask operation, which returns a string. The handler provides the implementation of ask: in this case, always returning "World". The effect type is checked: you cannot call greeting without providing a handler for ask.

This is a genuine axis of type system design that the lambda cube does not capture. Effects are about what happens during computation (side effects, control flow), not about what types are (the cube’s concern). A language can have algebraic effects without dependent types (Koka), or dependent types without algebraic effects (Agda), or both (some experimental systems).

Rust’s Implicit Effect Encoding

Rust handles effects without an effect system by encoding them in types and language features:

EffectEffect system approachRust’s approach
Failureeffect exn { ctl throw(e) }Result<T, E>, ? operator
Absenceeffect maybe { ctl none() }Option<T>, ? operator
Asynchronyeffect async { ctl yield() }async/await, Future trait
Mutationeffect state { ctl get(); ctl put(s) }&mut T references
I/Oeffect io { ctl read(); ctl write(s) }Not tracked in types

Rust’s approach is pragmatic: the most important effects are encoded in types (Result, Option, Future), one effect is tracked by a specialised sub-system (mutation via the borrow checker), and the rest (I/O, allocation, panicking) are untracked. This avoids the complexity of a general effect system while capturing the most common failure and absence patterns in the type system.

The trade-off is visible in unsafe code. Within an unsafe block, Rust’s effect guarantees (memory safety, data race freedom) are suspended — the programmer takes responsibility. An effect system would track exactly which guarantees are suspended and verify that they are restored. Rust’s unsafe is a coarse escape hatch where an effect system would provide fine-grained tracking.

Linear and Affine Type Systems

Rust’s ownership system is an affine type system: values can be used at most once (they can be moved). The borrow checker extends this with shared and exclusive references, creating a practical system for resource management. But affine types are not the only option in this space.

Linear types require that values be used exactly once — not zero times, not twice, but once. A linear value cannot be dropped without being consumed by some operation. Linear Haskell (-XLinearTypes) introduces linear function arrows:

-- Linear Haskell: the function must use its argument exactly once
dup :: a %1 -> (a, a)  -- TYPE ERROR: uses 'a' twice

The %1 annotation means “this argument must be used exactly once.” A function that duplicates its argument violates linearity and is rejected.

In Rust, dropping a value is always permitted (affine, not linear). The Drop trait provides a hook for cleanup, but the type system does not require that a value be consumed. A File handle can be dropped without being closed (the Drop implementation closes it, but the programmer could mem::forget it). With linear types, the type system would require explicit closing — the file handle must be consumed by a close operation, not silently dropped.

Granule (University of Kent) is a research language with graded modal types — a generalisation that subsumes linear, affine, and other resource-tracking disciplines under a single framework. In Granule, every variable carries a grade indicating how many times it may be used, and these grades can be combined algebraically.

Rust’s affine system is a pragmatic point in this design space. Full linearity would be more expressive (guaranteeing resource cleanup) but less ergonomic (requiring explicit consumption of every value). Rust’s Drop trait provides a practical compromise: resources are cleaned up automatically on drop, and the type system prevents use-after-move, without requiring that every value be explicitly consumed.

Session Types and Protocol Verification

Session types represent communication protocols as types. A session type describes the sequence of messages that must be sent and received on a channel, and the type checker verifies that both endpoints follow the protocol.

// Pseudocode: session-typed channel
type LoginProtocol = Send<Username, Recv<Challenge, Send<Response,
                     Branch<Recv<Token, End>, Recv<Error, End>>>>>;

This type describes a login protocol: send a username, receive a challenge, send a response, then branch — either receive a token (success) or an error (failure). The type checker verifies that the client and server implementations follow this protocol at each step.

Rust’s typestate pattern (Chapter 7) encodes a related but weaker property: it ensures that a single object goes through states in the correct order. Session types generalise this to two-party protocols, ensuring that both sides of a communication channel agree on the message sequence. The difference is concurrency: typestate is sequential (one object, one owner), while session types are concurrent (two endpoints, potentially in different threads or processes).

Several Rust crates (like session-types) implement session types using Rust’s ownership system. The ownership transfer (move semantics) naturally models the “use each protocol step exactly once” requirement. But these are library-level encodings, not first-class type system features, and they lack the compiler’s full verification power.

Rust’s Position as a Principled Choice

Rust does not occupy its position in the type system landscape by default or by accident. It is a principled choice, driven by the boundary.

The boundary requires:

  1. All type-level computation must complete at compile time. This rules out full dependent types (which may require runtime evaluation for type checking).
  2. All proofs must be erasable without runtime cost. This rules out first-class proof terms (which would need runtime representation).
  3. Monomorphisation must produce code equivalent to hand-written concrete code. This rules out higher-kinded type quantification (which would require runtime type-constructor dispatch).
  4. The type system must be decidable. This rules out unrestricted type-level computation (which could diverge during type checking).

These four requirements determine Rust’s position: λω (polymorphism + type operators), with a pinhole into dependent types (const generics), proof erasure (monomorphised trait dispatch), and decidable type checking (no type-level Turing completeness). Every feature of Rust’s type system is compatible with these requirements. Every feature beyond Rust violates at least one.

This is not a limitation to be regretted. It is a design that achieves something no other language achieves: a type system expressive enough for parametric polymorphism, trait-bounded generics, typestate machines, and lifetime-based memory safety — all at zero runtime cost, with a clean boundary between the type-level reasoning and the generated machine code.

The alternative designs are not wrong — they are different trade-offs:

  • Haskell trades zero-cost abstraction for higher-kinded types and lazy evaluation
  • Idris trades compilation speed and simplicity for full dependent types
  • Koka trades ecosystem size for algebraic effects
  • Go trades expressiveness for simplicity and fast compilation

Each language answers the same question differently: how much type-level structure should survive the boundary, and what is the programmer willing to pay for it? Rust’s answer is: as much structure as can be verified and erased at zero cost, and not one byte more.

The Curry-Howard Correspondence as Research Programme

This book has used the Curry-Howard correspondence as its organising principle: types are propositions, implementations are proofs, and Rust’s type system is a practical realisation of this correspondence with proof erasure at the boundary.

The correspondence itself is not a closed result. It is an ongoing research programme — a programme that has deepened and generalised steadily since Curry’s original observation in 1934.

The original correspondence (Curry 1934, Howard 1969) connected propositional logic with the simply typed lambda calculus. Implication corresponds to function types. This is Level 0.

The polymorphic extension (Girard 1972, Reynolds 1974) connected second-order logic with System F. Universal quantification corresponds to parametric polymorphism. This is Levels 1 and 2.

The dependent extension (Martin-Lof 1971, 1984) connected constructive predicate logic with dependent type theory. Dependent products (Π-types) correspond to universal quantification over values. This is Level 5.

The categorical extension (Lambek 1980, Seely 1984) connected the correspondence to category theory. Types are objects. Functions are morphisms. The type system is a category. This is the framework this book uses.

The homotopy extension (Voevodsky 2006, Univalent Foundations Program 2013) connected type theory to homotopy theory. Types are spaces. Equalities are paths. Higher equalities are homotopies. This is the frontier: Homotopy Type Theory (HoTT) uses the Curry-Howard correspondence to connect logic, computation, and geometry in a single framework.

Each extension deepens the correspondence. Each reveals new structure in the relationship between logic and computation. And each is, at its core, the same insight that animates this book: the structure of a type system is not an engineering convenience. It is a mathematical reality — a reflection of the deep identity between formal reasoning and computation.

Rust participates in this programme concretely. When a Rust programmer writes a trait bound, they are stating a proposition. When they write an impl block, they are constructing a proof. When the compiler erases the proof at the boundary, it is performing the type-theoretic operation of proof irrelevance — discarding logical structure that has served its purpose. Every day, in every Rust program, the Curry-Howard correspondence is at work.

Conclusion

The space of type systems is vast. Rust occupies a specific, carefully chosen position within it: expressive enough for systems programming, zero-cost enough for performance-critical applications, and principled enough that its type system forms a coherent logical framework.

The boundary — the forgetful functor F: 𝒯 → 𝒱 — is the architectural decision that determines this position. It is the mechanism that transforms type-level reasoning into runtime computation, the membrane that separates propositions from execution, and the guarantor of zero-cost abstraction. Understanding the boundary is understanding Rust.

Beyond the boundary lie dependent types, effect systems, universe polymorphism, session types, and the full Calculus of Constructions. These are not improvements Rust is missing — they are alternative boundary designs, each with its own trade-offs. Rust’s boundary is aggressive: it erases everything, preserves nothing, and charges zero. This is the right boundary for a systems language. It is not the only possible boundary, and the study of alternative boundaries is one of the most active areas of programming language research.

The type-theoretical perspective on Rust does not change what the language can do. It reveals why the language is the way it is. Types are propositions. Implementations are proofs. The boundary erases the proofs and keeps the computation. Zero-cost abstraction is not a feature — it is a property of the boundary. And the boundary is where everything begins.