Skip to content

The Rust Engineering Handbook / Chapter 25

Advanced Type Patterns

Use phantom markers, variance, higher-ranked bounds, associated relationships, opaque returns, and const parameters only when they make a concrete API misuse impossible.

The API review ended by making the type smaller

The proposed parser index carried a lifetime parameter, a record type, a storage mode, a generation number, and a const page size. Its methods added a generic associated type for borrowed fields and two higher-ranked callback bounds. Every feature was individually defensible. Together they made callers prove relationships the implementation did not use.

The review kept the generation-stamped handle and the borrowed-field relationship. It deleted the type-level storage mode, made page size a runtime configuration, and replaced a callback abstraction with a plain function. The smaller design encoded two failure classes that mattered—using a handle with the wrong record family and returning a field beyond the record borrow—without turning operational policy into a type puzzle.

That is the standard for advanced type patterns. Their value is not that they demonstrate fluency. Their value is that a precise relationship becomes mechanically enforced at every call site.

Controlling contract: Add type-level machinery when it makes a named invalid program unrepresentable or preserves a relationship that callers otherwise could violate. Keep policy, tuning, and accidental implementation structure at runtime unless the type system produces a durable, reviewable benefit.

The executable fixture uses a small typed handle:

pub struct PostingHandle<'a, T> {
    index: usize,
    marker: PhantomData<&'a T>,
}

The marker occupies no runtime storage, but it tells the compiler that the handle is related to a borrowed T for 'a. That affects variance, drop checking, and auto-trait reasoning. The fixture’s constructor does not receive a table borrow, so it demonstrates the marker’s type behavior rather than proving origin from a particular table. An arena API that needs that proof must create the handle from &'a Arena<T> or an equivalent branded borrow. Replacing the marker with another spelling is a semantic change even when size_of is unchanged.

PhantomData supplies a missing relationship

A generic parameter must be used by a type. When the runtime fields do not naturally contain it, PhantomData lets the definition state the relationship explicitly. Typical cases include raw-pointer wrappers, branded identifiers, typestate markers, FFI handles, and iterators whose safety depends on a borrow not stored as a reference.

The spelling inside PhantomData<...> matters:

  • PhantomData<T> models owning values of T for variance, drop checking, and auto traits.
  • PhantomData<&'a T> models a shared borrow for 'a.
  • PhantomData<&'a mut T> models an exclusive borrow; it is covariant in 'a but invariant in T.
  • PhantomData<*const T> and PhantomData<*mut T> model raw-pointer relationships with different variance and auto-trait consequences.
  • PhantomData<fn(T)> is a non-owning, contravariant use of T.

Do not select a marker by copying one from a similar-looking crate. Write down what the runtime fields logically own or borrow, whether the wrapper may outlive a value, and which thread-safety properties should propagate. The marker is part of the proof.

The PostingHandle example deliberately uses &'a T, not T. It models a borrow rather than claiming to own and later drop a T. In a complete arena API, a private constructor would derive 'a from the originating table borrow; leaving a public constructor unconstrained would let callers choose a lifetime that proves no origin at all. A generation number would still be checked at runtime because a lifetime cannot prove that a slot has not been deleted and reused while the table remains alive. Compile-time and runtime invariants complement rather than replace one another.

Phantom markers do not make unsafe code safe

PhantomData can make the compiler enforce a relationship that accurately describes an implementation. It cannot establish that a raw pointer is aligned, non-null, in bounds, initialized, or derived from the right allocation. A wrapper with unsafe internals still needs construction and method proofs. Choosing a plausible marker without auditing those facts can make an unsound abstraction look polished.

For a safe index-based fixture, the marker’s job is modest and testable: the handle is one usize at runtime, yet it cannot silently change its record type. The compile-time assertion records the size expectation:

const _: () = assert!(
    size_of::<PostingHandle<'static, Posting>>() == size_of::<usize>()
);

A schematic handle has the same runtime-sized slots in ownership-like and non-owning variants, while solid versus dashed marker relationships show that a zero-sized phantom marker changes the type relationship rather than the byte count.

This assertion is appropriate because the struct and expectation are controlled together. A public library should avoid asserting incidental sizes of external types unless the size is an explicit compatibility requirement.

Variance answers whether substitution is sound

Subtyping in Rust is intentionally limited. The relationship engineers encounter most often is between lifetimes: a reference valid for a longer lifetime can be used where a shorter lifetime is required. Variance determines whether a generic type preserves, reverses, or blocks such substitution.

For a constructor F<T>:

  • covariant means a subtype relationship between inputs is preserved by F;
  • contravariant means it is reversed;
  • invariant means neither substitution is generally permitted.

Concrete forms provide a better review tool than memorizing definitions:

Type position Lifetime variance T variance Engineering meaning
&'a T covariant in 'a covariant shared observation can be shortened
&'a mut T covariant in 'a invariant exclusive access forbids substituting a different T relationship
*const T covariant raw shared-address relationship; validity remains unchecked
*mut T invariant raw mutable-address relationship
fn(T) -> U contravariant in T, covariant in U a consumer that accepts more can stand in for one that accepts less
Cell<T> invariant interior mutation makes substitution capable of writing the wrong type
PhantomData<X> follows X follows X zero-sized marker imports X’s relationship

The fixture’s rejected program defines Invariant<'a>(PhantomData<&'a mut &'a str>) and attempts to turn an Invariant<'static> into Invariant<'short>. The compiler rejects the conversion because the nested mutable-reference use makes the lifetime invariant. Adding a cast, transmute, or raw pointer would not repair the model; it would merely move the obligation into unsafe code.

Variance is usually inferred. Public APIs should not expose it accidentally. A harmless field change can alter inferred variance and break downstream code or, around unsafe implementations, invalidate a proof. When a wrapper’s safety relies on variance, name the intended relationship in its safety documentation and preserve a compile-fail test for the forbidden substitution.

Drop checking asks what must still be valid at destruction

Borrow checking establishes that references are valid while used. Drop checking also considers what a destructor could observe when a value is dropped. If a generic container logically owns T, then values reachable through T may need to remain valid until the container’s destructor runs. PhantomData<T> can communicate that logical ownership when no runtime T field exists.

This becomes important in raw-owning containers and self-managed allocations, but the design rule applies earlier: do not use a non-owning marker merely to relax a lifetime error. First decide whether destruction can access or drop the logical T. If it can, the ownership relationship belongs in the type. If it cannot, use the marker that accurately models the non-owning relationship and document why.

Most safe application types should let ordinary references and owned fields express drop relationships. Advanced drop-check escape hatches and unstable annotations belong in carefully reviewed low-level libraries, not as a routine solution to inconvenient lifetimes.

Higher-ranked bounds quantify over the callee’s borrow

A bound such as F: Fn(&'a str) -> &'a str uses one lifetime selected in the surrounding context. Sometimes the required contract is stronger: the callable must work for any fresh borrow the callee creates. Rust writes that as a higher-ranked trait bound:

pub fn apply_borrowed<'a>(
    input: &'a str,
    operation: impl for<'b> Fn(&'b str) -> &'b str,
) -> &'a str {
    operation(input)
}

for<'b> means the operation cannot be specialized to one conveniently long borrow. It must preserve each input borrow independently. str::trim satisfies the relationship: the returned slice is derived from the supplied slice.

Use an HRTB when the implementation repeatedly creates short borrows, when a callback must not capture one caller-selected lifetime as its only valid input, or when a trait implementation must be valid across all borrow durations. Do not add it merely because a compiler suggestion mentions for<'a>. State the quantification in English first: “for every borrow the method creates…” If that sentence does not describe the API, the bound is likely wrong.

A frequent misleading fix is to require 'static. That does not mean “valid for any lifetime”; it means the referenced data can satisfy the static lifetime requirement. It may exclude borrowed application data and encourage cloning or leaking. Higher-ranked and 'static contracts solve different problems.

Generic associated types keep output borrows tied to self

An ordinary associated type chooses one type per implementation. A generic associated type chooses a family of types indexed by lifetimes or other parameters. Borrowing collections and parser views need this when each method call returns an item tied to that call’s borrow:

pub trait FieldSource {
    type Field<'a>: AsRef<str>
    where
        Self: 'a;

    fn field(&self, index: usize) -> Option<Self::Field<'_>>;
}

impl FieldSource for Record {
    type Field<'a> = &'a str;

    fn field(&self, index: usize) -> Option<Self::Field<'_>> {
        self.fields.get(index).map(String::as_str)
    }
}

The family Field<'a> preserves the relationship between the returned view and the borrowed record without forcing allocation. The associated type bound : AsRef<str> states a capability shared across every member of the family. The where Self: 'a clause makes the permitted relationship explicit.

GATs earn their keep for lending iterators, arenas, lock guards, decoded views, and storage engines that return implementation-specific borrowed objects. They do not automatically make a trait usable as dyn Trait, nor do they remove lifetime reasoning. If every implementation can simply return &str, use that concrete signature. If callers do not need to name or abstract over the family, a GAT may be unnecessary surface area.

Associated type bounds also appear outside GATs. Prefer placing capabilities near the relationship they govern, but avoid bounds that simply restate every current implementation. A public bound is a promise and a restriction on future implementations.

Return-position impl Trait hides a concrete result

Return-position impl Trait lets a function expose capabilities while hiding one concrete return type:

pub fn non_empty_fields(record: &Record) -> impl Iterator<Item = &str> {
    record
        .fields
        .iter()
        .map(String::as_str)
        .filter(|field| !field.is_empty())
}

This remains static dispatch. It does not mean “any iterator chosen at runtime,” and distinct return branches must resolve to the same hidden concrete type. The form is excellent for iterator adapters, closure returns, and public APIs that want freedom to change an unnameable implementation type without boxing.

Compare the alternatives deliberately:

  • return a named concrete type when callers benefit from its full API and the name is stable;
  • return impl Trait for one hidden concrete implementation with static dispatch;
  • return Box<dyn Trait> when runtime heterogeneity or a dynamic boundary justifies allocation/indirection;
  • return an enum when the alternatives are closed and should remain allocation-free;
  • define an associated type when implementations, rather than one function body, choose the result relationship.

Opaque types can still capture generic and lifetime parameters, affecting SemVer and borrow behavior. They hide the concrete name, not the observable trait contract or auto traits. If callers require Send, Clone, or an exact-size iterator, include that promise only after deciding it should remain stable.

Const generics move shape into the type

Const parameters represent values known at compile time:

pub struct Prefix<const N: usize>(pub [u8; N]);

impl<const N: usize> Prefix<N> {
    pub const fn as_array(&self) -> &[u8; N] {
        &self.0
    }
}

pub type HeaderPrefix = Prefix<4>;

Here N is a representation fact: Prefix<4> and Prefix<8> have different shapes and are different types. That can eliminate bounds checks, express protocol widths, and connect APIs to array lengths. It can also multiply monomorphized code, enlarge type signatures, and move configuration changes into compilation.

Use const generics for invariants intrinsic to the value’s shape: matrix dimensions, fixed packet prefixes, bounded inline storage, or hardware lane counts. Prefer runtime values for tuning knobs, deployment configuration, user-selected sizes, and values that need not create distinct code. A const parameter is not automatically validated; stable Rust supports a practical but deliberately limited set of const expressions in generic positions. Do not design a stable API around nightly-only generic const expressions unless the experimental boundary is isolated and optional.

Type aliases can recover readability, as HeaderPrefix does, but they do not create a new type. They add no validation, trait boundary, or distinct constructor. Use a newtype when identity or invariants matter; use an alias when the relationship is already correct and only the spelling is noisy.

Compile-time assertions are valuable for local invariants and representation checks. Keep their authority scoped. An assertion about a controlled repr(C) type on a declared target can guard an FFI build; an assertion about the current layout of a default-representation enum records an observation, not a language guarantee.

Stable capability versus unstable ambition

On the Rust 1.97.0 snapshot, the core mechanisms used here—PhantomData, HRTBs, GATs, return-position impl Trait, associated type bounds, common const generics, type aliases, and const assertions—are available on stable Rust. That does not make every adjacent formulation stable. Type-alias impl Trait, broad generic const expressions, and specialized implementation patterns remain examples of boundaries that must be checked rather than inferred from similar stable syntax.

For production APIs:

  1. write the stable formulation first;
  2. isolate any experimental implementation behind a private module, feature, or separate crate;
  3. keep the public contract expressible without nightly when possible;
  4. record the exact toolchain and feature gate for experiments;
  5. provide a removal or stabilization plan before downstream users depend on it.

“The compiler accepts it on nightly” is evidence of an experiment, not a compatibility policy.

Spend a type-complexity budget

Advanced typing imposes costs: longer diagnostics, more concepts for maintainers, greater compile time, more monomorphized code, harder mocks, and a larger SemVer surface. Review each mechanism against a named prevented failure.

Mechanism It earns its cost when Prefer something simpler when
phantom marker runtime fields omit a real ownership, borrow, or type relationship an ordinary field/reference already states it
invariance substitution could permit an invalid write or break an unsafe proof covariance is sound and more usable
HRTB a callable must work for every callee-created borrow one explicit lifetime relationship suffices
GAT each borrow/parameter selects a related output family one associated or concrete type suffices
return impl Trait one concrete implementation should remain unnamed runtime alternatives are required
const parameter the value determines intrinsic shape or protocol identity it is operational configuration
alias a correct type is merely unreadable a distinct invariant or identity is needed
const assertion a local compile-time fact is part of the supported contract the fact is an incidental compiler observation

Complexity that only shortens a function body does not automatically pass. The benefit should appear at call sites, in compiler rejection, or in a smaller unsafe proof.

Worked review: a generation-checked arena API

Consider an arena that stores parsed records and returns handles. Slots may be reused after deletion, so an integer index alone can identify the wrong record. The first proposal tries to solve everything statically:

struct Handle<'arena, Record, Generation, Mode, const SHARD: usize> {
    index: usize,
    marker: PhantomData<(&'arena Record, Generation, Mode)>,
}

The type contains impressive information, but review must ask which information remains true in a running system.

The record family is a genuine compile-time relationship. A Handle<User> should not index an Arena<Order>. A borrow lifetime may also be honest if the arena is immutably borrowed for the entire handle use. Generation, however, is commonly assigned at runtime when a slot is reused. Encoding every generation as a fresh Rust type is practical only inside a scoped branding API that creates a new invariant lifetime; it cannot model arbitrary persisted handles or values crossing a message boundary. Storage mode and shard number are likely operational policy. Making them type parameters multiplies types and prevents queues from holding otherwise equivalent handles without erasure.

A smaller design keeps the static record relationship and checks generation dynamically:

struct Handle<'arena, T> {
    slot: usize,
    generation: u32,
    marker: PhantomData<&'arena T>,
}

Lookup compares the stored generation with the handle generation before returning &T. The marker prevents cross-record substitution; the integer prevents stale-slot reuse. If handles must outlive a borrow or cross processes, remove 'arena, retain PhantomData<fn() -> T> or another accurately audited non-owning marker for type identity, and make arena identity plus generation explicit runtime data. A longer lifetime annotation cannot authenticate a deserialized handle.

Now consider the output API. A GAT is justified if different arenas return distinct guard or decoding-view types:

trait ArenaRead {
    type View<'a>
    where
        Self: 'a;

    fn get(&self, handle: RawHandle) -> Option<Self::View<'_>>;
}

If every implementation returns &Record, the GAT adds no useful freedom. If a memory-mapped arena returns a validation guard and an in-memory arena returns a direct reference, the family captures a real implementation-selected relationship. The review should prototype both implementations before freezing the public trait.

The same discipline applies to callbacks. A visitor invoked against many temporary views may need for<'a> FnMut(View<'a>). A visitor stored for later probably needs owned inputs or a callback lifetime tied to the arena. Adding 'static to silence storage errors changes the accepted data model; it is not a neutral bound.

This review produces four artifacts more valuable than a dense signature:

  1. a misuse table mapping wrong-record, stale-generation, wrong-arena, and escaped-view failures to their static or dynamic checks;
  2. a compile-fail test for cross-record or forbidden lifetime substitution;
  3. runtime tests for deletion, slot reuse, and generation wrap policy;
  4. a short rationale for every public generic parameter.

If a parameter has no row in that table, delete it until a concrete requirement appears.

Production effects extend beyond runtime speed

Advanced types are often described as zero-cost because marker fields are zero-sized and static dispatch can inline. Runtime cost is only one budget.

Compilation and code size

Every type or const combination can create another monomorphized instance. A generic inner loop may benefit; a generic orchestration layer instantiated across dozens of modes may increase build time and instruction-cache pressure without improving runtime. Use cargo llvm-lines, linker maps, build timings, or equivalent evidence when code size matters, and keep generic variability at a narrow edge. A concrete internal core can receive normalized references, integers, or enums after the public type boundary validates them.

Return-position impl Trait avoids dynamic dispatch but can spread a large adapter type through compilation. The hidden name improves API surface, not necessarily compile time. Boxing at a cold plugin or configuration boundary may be the better whole-system trade if it contains code growth and incremental rebuilds.

Diagnostics and team maintenance

Nested GAT, HRTB, and associated-type errors can expose compiler terminology far from the design decision. Public libraries should include small named helper traits only when they improve the conceptual model, not merely to shorten an error. Applications can often isolate an advanced boundary in one module and expose ordinary owned or borrowed types to the rest of the team.

Review how failures look. Preserve compile-fail tests, but also compile one representative misuse and read the diagnostic as a user. If the only repair path requires understanding an internal marker choice, add a constructor, named wrapper, or documentation example that makes the intended relationship visible.

Compatibility

Changing variance can break valid downstream coercions. Adding a bound to an opaque return type may be compatible as a new capability, while removing an auto trait such as Send is usually observable. Changing a const parameter, associated type family, or required where-clause can force downstream signatures to change. Type aliases do not insulate users from the underlying type’s compatibility surface.

Treat public advanced relationships as SemVer commitments. Keep raw implementation markers private when callers do not need their exact form. Document semantic promises—borrow duration, thread transfer, shape, and output capability—rather than promising inferred internals.

Concurrency and auto traits

Phantom marker choice can affect whether a wrapper is Send or Sync, because auto-trait reasoning follows the modeled type relationship. That is desirable only when it matches what the runtime handle can safely do. A raw address wrapper is not made thread-safe by choosing a marker that avoids propagating !Send; thread transfer also depends on allocation lifetime, synchronization, aliasing, and destructor behavior.

Never add an unsafe Send or Sync implementation merely to compensate for an inconvenient phantom choice. First correct the logical relationship, then write the concurrency proof. Chapter 71 will treat those composed obligations directly.

Panic, drop, and observability

Compile-time types do not decide what partial mutation means after panic. A const-sized buffer still needs initialization and cleanup logic; a GAT-returned guard may hold a lock; a typestate transition consuming self may need to preserve recoverable state. Document destructor effects and panic behavior where advanced wrappers manage resources.

Operational tooling also sees erased values, not type proofs. Logs for a generation-stamped handle should include arena identity, slot, and generation. Metrics should aggregate runtime modes deliberately even if modes are distinct Rust types. Encoding information in a type does not remove the need to expose the corresponding operational identity at diagnostic boundaries.

Trust boundaries and deserialization

Type-level evidence exists only after values have been constructed through trusted Rust paths. Bytes, database rows, foreign handles, and user input do not arrive with proven lifetimes, const relationships, or branded identities. A decoder must validate runtime representation before it creates the advanced type. Serde-style reconstruction, custom FFI conversion, and unsafe constructors deserve the same audit as ordinary public constructors; deriving deserialization for a type with hidden invariants may bypass the intended proof boundary.

A phantom brand must never be treated as an authentication token. If two tenants or arenas use the same Rust type, isolation requires runtime identity and authorization. A const generic identifying a protocol version does not prove the bytes obey that version. A typestate marker reconstructed from an unchecked integer does not prove the corresponding transition occurred.

At a trust boundary, parse into a deliberately weak representation, validate lengths, identities, generations, and policy, then construct the strong type. Keep unchecked constructors private or unsafe with explicit preconditions. This ordering makes the type system the endpoint of validation rather than a substitute for it, and it gives security review a concrete place to examine how external claims become compiler-enforced facts.

Repairs that compile but weaken the contract

Advanced signatures invite local escape hatches. Reject these unless they follow an explicit design change:

  • replacing a borrow-preserving HRTB with 'static and cloning every input;
  • boxing every opaque return because two branches have different adapter types, when an enum or reordered pipeline would preserve static dispatch;
  • using PhantomData<fn(T)> to regain covariance or auto traits when the wrapper logically owns T;
  • erasing a GAT view into owned String merely to simplify one caller, forcing allocation on all callers;
  • promoting runtime configuration into const generics so one assertion can run at compile time;
  • adding a type alias and claiming it prevents mixing two domain values;
  • moving a failed const assertion to a build script without recording target and feature scope;
  • introducing unsafe casts to restore a substitution that invariance intentionally rejected.

Each repair may make the current program compile. The review question is whether it preserves ownership, allocation, compatibility, and failure intent.

Exercise: remove what does not pay rent

Review a proposed buffer API with Buffer<'arena, T, Mode, const N: usize>, a PhantomData<&'arena mut T>, a GAT returning chunks, an HRTB visitor, and a nightly generic-const bound requiring N to be a multiple of eight.

Produce a two-page decision record:

  1. Name one invalid program each mechanism is intended to reject.
  2. Determine the real ownership model. If the buffer owns T, borrows it, or merely labels bytes as T, choose and justify the exact marker.
  3. Build a variance table for 'arena and T; add a compile-fail case for any required invariance.
  4. Decide whether chunks need an implementation-selected borrowed family or can return &[T].
  5. Decide whether the visitor must work for every chunk borrow or one caller lifetime.
  6. Compare N in the type with a runtime capacity for code size, configuration, and error handling.
  7. Replace the nightly constraint with validation, a finite set of supported newtypes, or an isolated experimental crate.
  8. Record compiler tests, MSRV, and the simpler rejected alternatives.

The review succeeds when every retained mechanism blocks a concrete misuse that a simpler stable design permits. “More precise” without a demonstrated failure is not sufficient.

Senior review questions

  • What relationship is absent from the runtime fields, and does the marker state it exactly?
  • Which parameters are covariant, contravariant, or invariant? Is any unsafe proof relying on that result?
  • What may a destructor observe, and does drop checking model logical ownership?
  • Does for<'a> truly mean every callee-selected borrow, or is 'static being used as a substitute?
  • Does a GAT represent a family of related outputs, or hide an avoidable abstraction?
  • Is impl Trait one opaque concrete type rather than a dynamic choice?
  • Is a const parameter intrinsic shape or merely configuration?
  • Does an alias improve spelling without being mistaken for a new invariant?
  • Are compile-time assertions checking a supported contract rather than freezing an accident?
  • Which adjacent feature is unstable, and is that boundary explicit?
  • What simpler design was tested, and which named misuse did it fail to prevent?

The closing rule for abstraction

Part IV began with generic variation and trait contracts. It ends with a constraint: the strongest type is not the type containing the most information. It is the smallest understandable type that preserves the relationships the system genuinely needs.

PhantomData can model an invisible borrow or ownership fact. Variance controls substitution. Drop checking protects values a destructor may observe. Higher-ranked bounds quantify over fresh borrows. GATs describe output families; opaque returns hide one implementation; const generics encode shape. Each is powerful because the compiler applies it everywhere. That same reach makes accidental complexity expensive.

The next part turns from relationships between types to their physical representation. The discipline remains the same: distinguish what Rust guarantees from what one compiler happens to produce, and encode only the contract the boundary truly needs.

Sources and version notes