Variance and PhantomData in Rust
Table of Contents
How Rust's variance system works, why std::thread::Scope needs invariant lifetimes, and what PhantomData actually does to prevent use-after-free.
std::thread::scope has this in its type:
pub struct Scope<'scope, 'env: 'scope> {
scope: PhantomData<&'scope mut &'scope ()>,
env: PhantomData<&'env mut &'env ()>,
// ...
}
The &'scope mut &'scope () isn’t a typo. It’s there to force 'scope to be invariant — and the reason why is actually where lifetimes get interesting.
Lifetimes Have Subtyping
Lifetimes have subtyping. 'a: 'b means 'a is a subtype of 'b, so a &'a T reference can stand in anywhere a &'b T one is expected. (Strictly, 'a is its own kind of generic parameter, not a Type in the kind system. It behaves like one where it counts.)
Subtyping brings variance: whether that relationship survives going through a wrapper like &T or Cell<T>.
- Covariant — preserves the subtyping direction
- Contravariant — flips it
- Invariant — blocks it entirely
Why Invariance Matters Here
If 'scope were covariant, a longer-lived scope could silently stand in for a shorter one. A thread spawned into it could outlive the data it borrowed. Use-after-free, borrow checker signs off.
So the standard library reaches for PhantomData and that deliberately ugly type to pin variance exactly where it needs to be.
PhantomData<&'scope mut &'scope ()> makes 'scope invariant because:
&'scope mut Tis invariant inTThere is&'scope (), which contains'scopeagain- The outer
&mutlocks down'scopein both positions — the compiler can’t shrink or stretch it
The result: Scope<'scope, 'env> can only be used with exactly the lifetime the caller gave it. No silent substitution, no soundness hole.
![]()