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:
-
Covariant:
F<S>is a subtype ofF<T>. The constructor preserves the subtyping direction. A read-only list of cats is a read-only list of animals — you can only take things out, and every cat you take out is an animal. -
Contravariant:
F<T>is a subtype ofF<S>. The constructor reverses the direction. A function that eats any animal can be used where a function that eats cats is needed — it handles cats and everything else. But a function that only eats cats cannot replace one that must eat any animal. -
Invariant: No subtyping relationship exists
between
F<S>andF<T>, regardless of the relationship betweenSandT. A mutable cell of cats is not a mutable cell of animals. If it were, you could put a dog in through the animal interface and the cat code would find a dog where it expected a cat. -
Bivariant:
F<S>andF<T>are subtypes of each other regardless ofSandT. The type parameter does not matter at all. This is the degenerate case — it meansFdoes not actually useT. Rust prevents this at the language level: unused type parameters are a compile error, forcing you to usePhantomDatato declare intent.
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:
-
PhantomData<T>— covariant inT. Use when the struct conceptually owns aT. -
PhantomData<&'a T>— covariant in'aand covariant inT. Use for shared borrows (likeIter). -
PhantomData<&'a mut T>— covariant in'a, invariant inT. Use for exclusive borrows (likeIterMut). -
PhantomData<fn(T)>— contravariant inT. Rarely needed. -
PhantomData<fn(T) -> T>orPhantomData<*mut T>— invariant inT. Use when the struct allows mutation through raw pointers.
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
- Variance answers one question: when can you substitute one type for another? Covariant = same direction (subtype preserved), contravariant = reversed, invariant = no substitution allowed.
- Rust's only subtyping is through lifetimes.
'long: 'shortmeans'longis a subtype of'short. Longer lifetimes are more specific. - Shared references are covariant because reading is safe. A
&'static strcan always be used where&'a stris expected. - Mutable references are invariant in T because writing is dangerous. If
&mut Catcould become&mut Animal, you could write a Dog through it. - 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.
- Function arguments are contravariant. A more general handler can replace a more specific one. A function accepting any
&strcan substitute for one accepting only&'static str. - Your struct's variance is determined by its most restrictive field. Adding a
Cellor&mut Tfield silently changes variance. This is a semver-breaking change. - Enums follow the same rule as structs. The variance is the most restrictive across all variants' fields.
- PhantomData controls variance for parameters that don't appear in fields.
PhantomData<&'a T>for covariance,PhantomData<&'a mut T>for invariance in T. - 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. - When a lifetime error makes no sense, check for invariance. The error will not say "variance." Look for
&mut,Cell,RefCell, orMutexin the types involved.
Further Reading
- The Rustonomicon: Subtyping and Variance — the official reference, concise but assumes background knowledge
- The Rust Reference: Subtyping and Variance — formal rules in the language specification
- The Rustonomicon: PhantomData — variance markers for unsafe code
- RFC 738: Variance — the original RFC that formalized Rust's variance rules
- Miri — an interpreter for Rust that detects undefined behavior, essential for validating unsafe variance choices