Variance and PhantomData in Rust

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 T is invariant in T
  • T here is &'scope (), which contains 'scope again
  • The outer &mut locks down 'scope in 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.

Rustonomicon: Scope and PhantomData