← index

Variance in Rust

This function looks correct. It overwrites one string reference with another through a mutable reference:

fn assign<'a>(input: &mut &'a str, value: &'a str) {
    *input = value;
}

fn main() {
    let mut s: &'static str = "hello";
    let local = String::from("world");
    assign(&mut s, &local);
    // ERROR: `local` does not live long enough
}

The compiler rejects it. The variable s is declared as &'static str, and the function ties both parameters to the same lifetime 'a. Because &mut T is invariant in T, the compiler cannot shorten 'static to match local's lifetime — so local must live as long as 'static, which it cannot. The problem is about how lifetimes interact when nested inside mutable references — a concept called variance.

Variance is a concept from type theory that determines when one type can be substituted for another. Rust uses it pervasively — every reference, every Cell, every function pointer has variance rules the compiler enforces — but the word "variance" never appears in error messages. Understanding it turns cryptic lifetime errors into predictable, mechanical behavior. This article builds variance from first principles, then applies each rule to Rust with code you can paste and verify.

All code examples were tested with Rust 1.94.1 (edition 2024). Error messages may vary slightly across compiler versions, but the variance rules themselves are stable.

What Variance Means

Before any Rust, the concept itself. Subtyping is a relationship between types: if S is a subtype of T, then an S can be used anywhere a T is expected. Cat is a subtype of Animal. Anywhere you need an Animal, a Cat will do.

Variance answers a follow-up question. Given a type constructor F<T> — some generic type that wraps T — and given that S is a subtype of T, what is the relationship between F<S> and F<T>?

There are four possible answers, given S <: T:

These are not abstractions. They are soundness constraints. C++ demonstrates what happens when the type system allows a covariant substitution on a mutable container. Arrays in C++ decay to pointers, and Derived* implicitly converts to Base*. This makes arrays covariant through the back door:

struct Animal { virtual ~Animal() = default; int weight = 0; };
struct Cat : Animal { int whiskers = 6; double purr_freq = 440.0; char name[32] = "mittens"; };
struct Dog : Animal { int bark_volume = 10; };

void add_dog(Animal arr[], int i) {
    arr[i] = Dog();  // sizeof(Animal)=16, sizeof(Cat)=56
}

int main() {
    Cat cats[4];
    add_dog(cats, 1);  // compiles: Cat[] decays to Cat*, Cat* converts to Animal*
    // arr[1] is addressed at offset 16 (sizeof(Animal)) but cats[1] really
    // lives at offset 56 (sizeof(Cat)). The assignment writes Animal's
    // member(s) into the middle of cats[0], silently corrupting it.
}

The compiler accepts this because Cat* converts to Animal*, and the array parameter is just a pointer. At runtime, arr[1] = Dog() indexes using sizeof(Animal) stride, but the actual array has sizeof(Cat) stride. When the sizes differ, the write lands at the wrong offset — in this case, inside cats[0], silently corrupting its fields. No exception, no diagnostic, just undefined behavior. The array is mutable, so it should be invariant, not covariant. C++ allows the implicit pointer conversion anyway.

Rust does not make this mistake.

Subtyping in Rust: It's All Lifetimes

Most languages implement subtyping through class hierarchies. Cat extends Animal, so Cat is a subtype of Animal. Rust has no class hierarchy. Structs do not inherit from other structs. String is not a subtype of str.

The only subtyping in Rust is through lifetimes. If 'long: 'short (read: 'long outlives 'short), then 'long is a subtype of 'short. A longer lifetime is more specific — it can be used in more places — just as Cat can be used wherever Animal is expected.

This means &'static str can be used anywhere &'a str is expected, because 'static outlives every other lifetime:

fn print_str<'a>(s: &'a str) {
    println!("{}", s);
}

fn main() {
    let s: &'static str = "hello";
    print_str(s); // works: 'static is a subtype of any 'a
}

The reverse does not work. A reference with a short lifetime cannot be used where a 'static reference is expected:

fn needs_static(s: &'static str) {
    println!("{}", s);
}

fn main() {
    let local = String::from("hello");
    needs_static(&local);
    // ERROR: `local` does not live long enough
}

This is not a metaphor. The Rust compiler literally applies subtyping rules from type theory, with lifetimes as the types being related. Every variance rule in this article flows from this one fact.

Coercion is not subtyping. When you pass a &String where a &str is expected, that is a deref coercion — the compiler inserts a call to Deref::deref. String is not a subtype of str. Variance rules do not apply to coercions. If you are reasoning about variance, only lifetime relationships matter.

T: 'static is not &'static T. The bound T: 'static means T contains no non-static references — it owns all its data. String satisfies T: 'static. &'static T means the reference itself lives forever. These are different concepts. The bound constrains what types T can be. The reference constrains how long you can use it.

Covariance: Reading Is Safe

&'a T is covariant in 'a and covariant in T. If 'long is a subtype of 'short, then &'long T is a subtype of &'short T. You can pass a longer-lived reference where a shorter-lived one is expected. The covariance in T matters when T itself contains lifetimes: &'a &'b str is covariant in both 'a and 'b, because each layer of referencing preserves the subtyping direction.

The intuition: shared references only let you read. If someone asks for "a reference to some animal" and you hand them "a reference to a cat," they can look at it, inspect its properties, pass it along — all safe. They cannot mutate through a shared reference, so they cannot put a dog where the cat is. Reading a more specific value through a more general type is always safe.

fn print_ref<'short>(s: &'short str) {
    println!("{}", s);
}

fn main() {
    let long: &'static str = "hello";
    print_ref(long); // works: &'static str is a subtype of &'short str
}

This extends to structs. A struct containing &'a str is covariant in 'a — the struct inherits the covariance of its field:

struct Message<'a> {
    text: &'a str,
}

fn print_message<'short>(msg: Message<'short>) {
    println!("{}", msg.text);
}

fn main() {
    let msg = Message { text: "hello" }; // 'static lifetime
    print_message(msg); // works: Message<'static> is a subtype of Message<'short>
}

The same principle applies to all owned containers. Box<T>, Vec<T>, Option<T>, Rc<T>, and Arc<T> are all covariant in T. Owned data that provides no mutation path through shared references is safe to treat covariantly — you only read from it or consume it, never write a different type into it through a shared alias.

Contravariance: Function Arguments Flip

This is the hardest variance rule. Take it slowly.

Function pointer types are contravariant in their argument types. If 'long is a subtype of 'short, then fn(&'short str) is a subtype of fn(&'long str) — the direction flips. A function that accepts references with shorter lifetimes is more useful than one that only accepts longer ones.

The intuition: think about what a function promises. A function typed for<'a> fn(&'a str) promises to handle references of any lifetime — including very short-lived, temporary strings. A function typed fn(&'static str) only promises to handle references that live forever. The first function is more capable. It can do everything the second can, and more. A more capable function can replace a less capable one, so for<'a> fn(&'a str) is a subtype of fn(&'static str).

In Rust, the most general form uses higher-ranked lifetimes. A function that handles any lifetime is written for<'a> fn(&'a str):

fn general(s: &str) {
    println!("{}", s);
}

fn static_only(s: &'static str) {
    println!("{}", s);
}

fn main() {
    // for<'a> fn(&'a str) is a subtype of fn(&'static str)
    let f: fn(&'static str) = general; // works: general handles anything
    f("hello");

    // The reverse: fn(&'static str) is NOT a subtype of for<'a> fn(&'a str)
    // let g: fn(&str) = static_only;
    // ERROR: mismatched types
}

Why must function arguments be contravariant? Consider what would happen if they were covariant — if a function that only handles 'static could be used where one handling any lifetime is needed:

// HYPOTHETICAL: if function arguments were covariant, this would compile
static mut STORED: &str = "";

fn static_only(s: &'static str) {
    // This function assumes s lives forever and stores it.
    unsafe { STORED = s; }
}

fn call_with_local(f: fn(&str)) {
    let local = String::from("temporary");
    f(&local); // passes a short-lived reference
    // local is dropped here
}

fn main() {
    // If covariant: fn(&'static str) could substitute for fn(&str)
    call_with_local(static_only);
    // static_only stored a reference to the now-dropped local.
    // unsafe { println!("{}", STORED); } // use-after-free
}

Contravariance prevents this. The compiler knows that fn(&'static str) is less capable than fn(&str), not more, so the substitution is rejected.

fn() vs Fn(): Contravariance applies to bare fn() pointer types, which are concrete types with known variance. The closure traits Fn(), FnMut(), and FnOnce() are trait bounds, not concrete types — the variance of a dyn Fn(&'a str) depends on how the trait object interacts with the lifetime parameter, which in practice makes it invariant in 'a. If you store a closure in a struct and encounter unexpected lifetime errors, try switching to a fn() pointer if the closure does not capture anything.

Invariance: Mutability Changes Everything

Covariance and contravariance are the cases where substitution works. Invariance is where it breaks — and understanding why it breaks is the core of understanding variance in Rust.

&'a mut T is covariant in 'a but invariant in T. You can shorten the borrow lifetime — a &'long mut T can be used as &'short mut T — but you cannot change T at all. Not to a subtype, not to a supertype. T must match exactly.

Why? Because mutable references allow both reading and writing. If you could coerce &mut &'static str to &mut &'a str, you could write a short-lived &'a str through it, and the original variable — still typed &'static str — would hold a dangling pointer.

Here is the exploit. This code does not compile, but if &mut T were covariant in T, it would:

fn assign<'a>(input: &mut &'a str, value: &'a str) {
    *input = value;
}

fn main() {
    let mut s: &'static str = "hello";

    {
        let local = String::from("temporary");

        // If &mut T were covariant in T:
        // &mut &'static str could become &mut &'a str
        // Then we could write &local (a short-lived ref) into s
        assign(&mut s, &local);
    }
    // local is dropped. s still exists, typed &'static str,
    // but now pointing to freed memory.

    println!("{}", s); // use-after-free
}

This is the opening example, expanded to show what would happen if the code compiled. The compiler rejects it because &mut T is invariant in T. The &mut &'static str cannot be coerced to &mut &'a str even though 'static: 'a. Invariance blocks the substitution, preventing the dangling pointer.

The same principle applies to interior mutability. Cell<T>, RefCell<T>, UnsafeCell<T>, Mutex<T>, and RwLock<T> are all invariant in T. These types allow mutation through a shared & reference, which is even more dangerous than &mut T — multiple parts of the program can hold a &Cell<T> simultaneously, so any variance hole is exploitable from anywhere.

The Cell case is the clearest demonstration of why interior mutability forces invariance. Walk through it step by step:

use std::cell::Cell;

// Suppose Cell<T> were covariant in T. Here is what would happen:

fn main() {
    // Step 1: Create a Cell holding a &'static str.
    let cell: Cell<&'static str> = Cell::new("hello");

    {
        let local = String::from("temporary");

        // Step 2: If Cell were covariant, Cell<&'static str> would be
        //         a subtype of Cell<&'a str> (since 'static: 'a).
        //         We could pass &cell to a function expecting &Cell<&'a str>.

        // Step 3: Through that coerced reference, we write a short-lived
        //         &'a str into the cell:
        cell.set(&local);
        // ERROR: `local` does not live long enough

        // Step 4: If this compiled, the Cell's inner value — still typed
        //         &'static str from the outside — would actually hold
        //         a reference to `local`.
    }
    // Step 5: local is dropped. cell.get() returns &'static str,
    //         but the data it points to is freed. Dangling pointer.

    println!("{}", cell.get()); // would be use-after-free
}

The compiler rejects the cell.set(&local) call. The cell has type Cell<&'static str>, and &local has a shorter lifetime. Because Cell is invariant in T, the compiler does not allow &local (a &'short str) where &'static str is required — even though 'static outlives 'short. Invariance blocks the substitution in both directions. Without it, every Cell holding a reference would be a potential dangling pointer.

The unifying principle: any type through which you can write a value of type T must be invariant in T. For &mut T, the mutation is through the exclusive reference. For Cell, RefCell, and Mutex, the mutation is through interior mutability — a shared & reference that permits writes. The mechanism differs, but the consequence is the same: if you can write, you cannot substitute.

Variance of Common Types

The following table lists the variance of standard library types. The "Why" column traces each entry back to the principle from the sections above.

Type Variance in 'a Variance in T Why
&'a T covariant covariant Read-only
&'a mut T covariant invariant Read + write
Box<T> covariant Owned, no aliasing
Vec<T> covariant Owned, no aliasing
Option<T> covariant Owned wrapper
Rc<T> covariant T is immutable through &Rc
Arc<T> covariant T is immutable through &Arc
Cell<T> invariant Interior mutability
RefCell<T> invariant Interior mutability
UnsafeCell<T> invariant Interior mutability primitive
Mutex<T> invariant Interior mutability
RwLock<T> invariant Interior mutability
*const T covariant Read-only raw pointer
*mut T invariant Mutable raw pointer
fn(T) -> U contra in T, cov in U Arguments flip, returns preserve
PhantomData<T> covariant Behaves as if it stores T
PhantomData<fn(T)> contra Behaves as if it consumes T
PhantomData<fn(T) -> T> invariant Contra + cov cancel out

The pattern: owned data with no mutation path through aliases is covariant. Any type through which you can write — whether via &mut, interior mutability, or raw pointers — is invariant in the type being written. Function arguments reverse the direction. Return values preserve it. For nested types, compose the variances: Box<&'a str> is covariant in 'a because Box is covariant in T and &'a str is covariant in 'a — covariant composed with covariant is covariant.

You can test variance empirically. If you are unsure whether a type F<'a> is covariant in 'a, try to assign F<'static> to a variable of type F<'short>. If it compiles, the type is covariant. If it does not, try the reverse. If neither direction compiles, the type is invariant:

use std::cell::Cell;

// Covariant: this compiles because &'a str is covariant in 'a.
// &'static str can be used where &'short str is expected.
fn is_covariant<'short>(_evidence: &'short str) {
    let s: &'static str = "hello";
    let _: &'short str = s; // works: covariant
}

// Invariant: this does NOT compile because Cell<T> is invariant.
// Cell<&'static str> cannot become Cell<&'short str>.
// fn is_invariant<'short>(_evidence: &'short str) {
//     let c: Cell<&'static str> = Cell::new("hello");
//     let _: Cell<&'short str> = c;
//     // ERROR: lifetime may not live long enough
// }

fn main() {
    let local = String::from("test");
    is_covariant(&local);
}

Structs and Enums Inherit Variance

A user-defined struct or enum does not declare its variance explicitly. The compiler infers it from the fields. The rule: a type's variance in a parameter is determined by the most restrictive use of that parameter across all fields.

If every field that uses 'a is covariant in 'a, the struct is covariant. If any field is invariant, the struct is invariant — invariance wins over everything. If one field is covariant and another is contravariant in the same parameter, the two directions conflict and the struct becomes invariant.

// Covariant in 'a: the only field is &'a str (covariant)
struct Ref<'a> {
    data: &'a str,
}

fn use_ref<'short>(r: Ref<'short>) {
    println!("{}", r.data);
}

fn main() {
    let r = Ref { data: "hello" }; // Ref<'static>
    use_ref(r); // works: Ref is covariant in 'a
}

A common source of confusion: &'a mut T is covariant in 'a. A struct containing only &'a mut str is covariant in 'a — you can shorten the borrow lifetime. It is the type parameter T that is invariant, not the lifetime.

Now add a Cell:

use std::cell::Cell;

struct Ref<'a> { data: &'a str }

// Adding Cell makes the struct invariant
struct CellRef<'a> {
    data: &'a str,
    cached: Cell<&'a str>,
}

// Ref is covariant — this compiles:
fn covariant_ok<'short>(_s: &'short str) {
    let r: Ref<'static> = Ref { data: "hello" };
    let _: Ref<'short> = r; // OK: Ref<'static> becomes Ref<'short>
}

// CellRef is invariant — this does NOT compile:
// fn invariant_err<'short>(_s: &'short str) {
//     let r: CellRef<'static> = CellRef {
//         data: "hello",
//         cached: Cell::new("hello"),
//     };
//     let _: CellRef<'short> = r;
//     // ERROR: lifetime may not live long enough
// }

fn main() {}

Same pattern, different result. Ref<'static> can be used as Ref<'short> because Ref is covariant. CellRef<'static> cannot become CellRef<'short> because the Cell<&'a str> field forces invariance in 'a, and invariance wins.

This is a practical lesson for library authors: adding a Cell, RefCell, or Mutex field to a struct silently changes its variance. Downstream code that relied on covariance will stop compiling. This is a semver-breaking change that no tool will flag automatically.

Enums follow the same rule. The variance is the most restrictive across all variants:

// Covariant in 'a: both variants use 'a covariantly
enum MaybeRef<'a> {
    Some(&'a str),
    None,
}

fn use_maybe<'short>(m: MaybeRef<'short>) {
    match m {
        MaybeRef::Some(s) => println!("{}", s),
        MaybeRef::None => println!("none"),
    }
}

fn main() {
    let m = MaybeRef::Some("hello"); // MaybeRef<'static>
    use_maybe(m); // works: covariant
}

Option<&'a str> is covariant in 'a because both Some(&'a str) and None are covariant (or do not use 'a at all). If any variant contained a Cell<&'a str>, the entire enum would become invariant.

PhantomData: Controlling Variance

Sometimes a struct has a lifetime or type parameter that does not appear in any field. This happens when you build abstractions over raw pointers — the pointer carries no lifetime information, but the abstraction needs to express ownership or borrowing relationships. Rust rejects unused type parameters:

struct MyIter<'a, T> {
    ptr: *const T,
    end: *const T,
    // ERROR: parameter `'a` is never used
}

PhantomData solves this. It is a zero-sized type that tells the compiler "pretend this struct contains a value of this type" without actually storing anything:

use std::marker::PhantomData;

struct MyIter<'a, T> {
    ptr: *const T,
    end: *const T,
    _marker: PhantomData<&'a T>,
}
// MyIter is now covariant in 'a and covariant in T,
// as if it contained a &'a T

The choice of PhantomData type determines the variance. Different markers produce different variance:

The standard library uses this extensively. std::slice::Iter<'a, T> contains raw pointers internally but declares PhantomData<&'a T> for covariance. std::slice::IterMut<'a, T> uses PhantomData<&'a mut T> for invariance in T. The wrong choice would be a soundness bug.

Choosing the wrong PhantomData is one of the most dangerous mistakes in unsafe Rust. If your struct allows mutation through a safe API and you use PhantomData<T> (covariant) instead of PhantomData<fn(T) -> T> (invariant), the compiler will allow lifetime coercions that create dangling references. The code compiles, the safe API looks correct, and the program has undefined behavior. We will construct this exact exploit in the unsafe code section below.

Trait Objects and Variance

Structs and PhantomData cover most variance scenarios, but there is one more common pattern: storing closures and trait objects in structs. Trait objects introduce their own lifetime parameter. Box<dyn Trait + 'a> is covariant in 'a — a boxed trait object with a longer lifetime can be used where one with a shorter lifetime is expected:

trait Greet {
    fn greet(&self) -> &str;
}

struct Hello;

impl Greet for Hello {
    fn greet(&self) -> &str {
        "hello"
    }
}

fn print_greeting<'a>(obj: Box<dyn Greet + 'a>) {
    println!("{}", obj.greet());
}

fn main() {
    let obj: Box<dyn Greet + 'static> = Box::new(Hello);
    print_greeting(obj); // works: 'static is a subtype of 'a
}

When closures are stored in structs as trait objects, the captured environment's lifetimes interact with the struct's variance. A closure that captures &'a str bakes that lifetime into its type. The + 'a bound on the trait object reflects this:

struct Processor<'a> {
    prefix: &'a str,
    handler: Box<dyn Fn(&str) + 'a>,
}

fn use_processor<'short>(p: Processor<'short>) {
    (p.handler)("hello");
}

fn main() {
    let owned = String::from("> ");
    let prefix: &str = &owned;
    let p = Processor {
        prefix,
        handler: Box::new(move |s| println!("{}{}", prefix, s)),
    };
    use_processor(p); // works: 'a inferred consistently from owned's lifetime
}

Here both prefix and the closure's captured reference share the same lifetime 'a, so the types are consistent. The general rule: dyn Trait + 'a is covariant in 'a, but the struct's overall variance depends on all its fields. If any field introduces interior mutability — for instance, a Cell<&'a str> alongside the trait object — the struct becomes invariant in 'a.

Diagnosing Variance Errors

Variance errors never say "variance." They say "lifetime mismatch," "types are not compatible," or "cannot infer an appropriate lifetime." Recognizing the pattern is the skill.

Pattern 1: &mut with mismatched inner types. You have a &mut Vec<&'a str> and a function expects a different lifetime. The &mut makes the Vec's type parameter invariant:

fn push_str<'a>(v: &mut Vec<&'a str>, s: &'a str) {
    v.push(s);
}

fn main() {
    let mut v: Vec<&'static str> = vec!["hello"];
    let local = String::from("world");

    // push_str(&mut v, &local);
    // ERROR: `local` does not live long enough
    //
    // The &mut makes Vec's type parameter invariant.
    // Vec<&'static str> cannot become Vec<&'a str>.
}

Pattern 2: Cell or RefCell with references. This is the same invariance rule from the Cell example above, but in practice it appears as a lifetime inference failure when calling a function:

use std::cell::Cell;

fn update<'a>(cell: &Cell<&'a str>, s: &'a str) {
    cell.set(s);
}

fn main() {
    let cell: Cell<&'static str> = Cell::new("hello");
    let local = String::from("world");

    // update(&cell, &local);
    // ERROR: `local` does not live long enough
    //
    // Cell is invariant in T. Cell<&'static str> cannot
    // be treated as Cell<&'a str>.
}

Pattern 3: Adding a field breaks downstream code. You add a Cell or RefCell field to a struct and existing code that uses the struct stops compiling. This is the scenario from the structs section above — the new field changed the struct's variance from covariant to invariant.

Pattern 4: Closures capturing mutable references. A closure that captures &'a mut Vec<T> is covariant in 'a (you can shorten the borrow lifetime), but invariant in T (through the &mut). The invariance in T typically manifests when returning closures or storing them in structs where T involves a lifetime:

fn make_pusher<'a>(v: &'a mut Vec<String>) -> impl FnMut(String) + 'a {
    move |s| v.push(s)
}

fn main() {
    let mut data = vec![];
    let mut push = make_pusher(&mut data);
    push(String::from("hello"));
    push(String::from("world"));
    drop(push);
    println!("{:?}", data);
}

This works because the lifetime 'a is used consistently. But if you try to store this closure in a struct alongside the Vec itself, the mutable borrow's invariance creates a conflict — the struct cannot simultaneously own the data and hold a mutable borrow into it. The borrow checker rejects self-referential structs for multiple reasons, but invariance of &mut T is one of the constraints that makes them impossible to express safely.

Diagnostic technique: when a lifetime error makes no sense, substitute 'static for the problematic lifetime. If the code compiles with 'static but not with a shorter lifetime, something is blocking covariance. Look for &mut, Cell, RefCell, or Mutex in the types involved — one of them is forcing invariance.

Variance and Unsafe Code: Where It Becomes UB

In safe Rust, the compiler enforces variance. You cannot write code that violates the rules — you get a compile error. In unsafe code, you can construct types that lie about their variance, and the compiler trusts you. If the lie is wrong, the result is not a compile error or a runtime panic. It is undefined behavior.

Here is a complete, self-contained example of a soundness bug caused by wrong variance. The struct wraps a raw pointer and uses the wrong PhantomData:

use std::marker::PhantomData;

/// A Cell-like wrapper around a heap-allocated value.
/// BUG: PhantomData<T> makes this covariant in T.
/// It should be invariant because we allow mutation through &self.
struct BadCell<T: Copy> {
    ptr: *const (),           // type-erased pointer: no variance info for T
    _marker: PhantomData<T>, // WRONG: covariant in T
}

impl<T: Copy> BadCell<T> {
    fn new(val: T) -> Self {
        BadCell {
            ptr: Box::into_raw(Box::new(val)) as *const (),
            _marker: PhantomData,
        }
    }
    fn set(&self, val: T) {
        unsafe { *(self.ptr as *mut T) = val; }
    }
    fn get(&self) -> T {
        unsafe { *(self.ptr as *const T) }
    }
}

impl<T: Copy> Drop for BadCell<T> {
    fn drop(&mut self) {
        unsafe { drop(Box::from_raw(self.ptr as *mut T)); }
    }
}

/// This function ties the cell's type to the value's lifetime.
/// Because BadCell is (wrongly) covariant, the caller can pass
/// BadCell<&'static str> here and the compiler coerces it.
fn exploit<'a>(cell: &BadCell<&'a str>, val: &'a str) {
    cell.set(val);
}

fn main() {
    let bad: BadCell<&'static str> = BadCell::new("hello");

    {
        let local = String::from("temporary");
        // Covariance allows &BadCell<&'static str> to become
        // &BadCell<&'a str> where 'a is local's lifetime.
        exploit(&bad, &local);
    }
    // local is dropped. bad still typed BadCell<&'static str>.

    println!("{}", bad.get()); // use-after-free: prints garbage or crashes
}

This compiles with no errors or warnings. Run it under Miri (cargo +nightly miri run) to confirm the undefined behavior: "constructing invalid value of type &str: encountered a dangling reference (use-after-free)."

The fix is one line — change the PhantomData to declare invariance in T:

use std::marker::PhantomData;

struct BadCell<T: Copy> {
    ptr: *const (),
    _marker: PhantomData<fn(T) -> T>, // CORRECT: invariant in T
}

With this change, the compiler rejects the exploit(&bad, &local) call. The BadCell<&'static str> cannot be coerced to BadCell<&'a str> because the type is now invariant in T. The soundness bug is gone.

This is why library authors writing unsafe code must understand variance. The safe API surface looks correct. The types check out. No unsafe block appears in the caller's code. But the wrong PhantomData in the library's internals allows safe callers to trigger undefined behavior. This is the definition of an unsound API.

The standard library gets this right. Vec<T> internally stores a raw pointer to its heap allocation, but its PhantomData is set up to make Vec covariant in T. This is safe because although Vec allows mutation, it only does so through owned or exclusive access — &Vec<T> does not let you mutate the contents (you need &mut Vec<T>). Contrast with &Cell<T>, which does let you mutate the contents through a shared reference. That is why Cell must be invariant and Vec can be covariant.

Higher-Ranked Lifetimes and Variance

One last topic that intersects with variance: higher-ranked trait bounds. The contravariance section used for<'a> fn(&'a str) without fully explaining the for<'a> syntax. Here is what it means and why it matters. A for<'a> fn(&'a str) is a function that works for any lifetime. This makes it a subtype of fn(&'specific str) for any specific lifetime. This is the contravariance rule from earlier, taken to its logical conclusion: the function that handles the widest range of inputs is the most substitutable.

You encounter this when passing closures to higher-order functions. The closure must handle references of whatever lifetime the caller provides:

fn apply(f: impl for<'a> Fn(&'a str) -> &'a str, s: &str) -> &str {
    f(s)
}

fn main() {
    let result = apply(|s| s, "hello");
    println!("{}", result);
}

The closure |s| s works for any lifetime, so it satisfies the for<'a> bound. But if the closure captured a specific reference, it would be tied to that reference's lifetime and could not satisfy the universal bound:

fn apply(f: impl for<'a> Fn(&'a str) -> &'a str, s: &str) -> &str {
    f(s)
}

fn main() {
    let prefix = String::from("hello: ");

    // This closure captures &prefix, tying it to prefix's lifetime.
    // It cannot satisfy for<'a> Fn(&'a str) -> &'a str
    // because the return value's lifetime depends on prefix, not on 'a.
    // let f = |_s: &str| -> &str { prefix.as_str() };
    // apply(f, "world");
    // ERROR: `prefix` does not live long enough
}

The compiler rejects this because the for<'a> bound requires the closure to work for any lifetime, but the return value's lifetime is tied to prefix, not to the input 'a. The fix is typically to restructure so the closure does not capture the problematic reference, or to change the bound from for<'a> to a specific lifetime.

Key Takeaways

  1. Variance answers one question: when can you substitute one type for another? Covariant = same direction (subtype preserved), contravariant = reversed, invariant = no substitution allowed.
  2. Rust's only subtyping is through lifetimes. 'long: 'short means 'long is a subtype of 'short. Longer lifetimes are more specific.
  3. Shared references are covariant because reading is safe. A &'static str can always be used where &'a str is expected.
  4. Mutable references are invariant in T because writing is dangerous. If &mut Cat could become &mut Animal, you could write a Dog through it.
  5. Cell and RefCell are invariant because interior mutability is mutation. Any type that allows writing through a shared reference must be invariant to prevent dangling references.
  6. Function arguments are contravariant. A more general handler can replace a more specific one. A function accepting any &str can substitute for one accepting only &'static str.
  7. Your struct's variance is determined by its most restrictive field. Adding a Cell or &mut T field silently changes variance. This is a semver-breaking change.
  8. Enums follow the same rule as structs. The variance is the most restrictive across all variants' fields.
  9. PhantomData controls variance for parameters that don't appear in fields. PhantomData<&'a T> for covariance, PhantomData<&'a mut T> for invariance in T.
  10. Wrong variance in unsafe code is not a compile error — it is undefined behavior. The compiler trusts your PhantomData. If it lies, safe code can create dangling pointers.
  11. When a lifetime error makes no sense, check for invariance. The error will not say "variance." Look for &mut, Cell, RefCell, or Mutex in the types involved.
* * *

Further Reading