← Back to list

🦀 Variance in Rust Explained

The hidden rules of type substitution

Enzo Lombardi in Rustaceans · 2026-06-23 11:11 · 54 claps · 8.0 min read paywalled
#rust #programming #type-systems #software-development #memory-safety
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment 💻 · Programming

🦀 Variance in Rust Explained

The hidden rules of type substitution

Rust’s type system enforces rules you never explicitly write. When you pass a reference to a function, the compiler silently decides whether a longer lifetime can substitute for a shorter one. When you use a generic type, it determines whether Container<&'long T> can become Container<&'short T>. These decisions follow a system called variance, and understanding it transforms confusing compiler errors into predictable behavior.

Most Rust programmers encounter variance indirectly. They write code that seems reasonable, the compiler rejects it, and they add lifetime annotations until it works. This trial-and-error approach obscures something elegant: a small set of rules governs all these behaviors. Learn the rules, and you’ll predict what the compiler accepts before you write the code.

Variance answers a deceptively simple question: when can one type substitute for another? The answer depends on where that type appears. A type that works as a function argument might fail as a return value. A type safe in an immutable reference becomes dangerous in a mutable one. The rules aren’t arbitrary. They prevent real bugs.

Subtyping through lifetimes

Rust doesn’t have traditional object-oriented subtyping. You can’t substitute a Dog for an Animal. But Rust does have subtyping, and it operates through lifetimes.

The rule is straightforward: 'long is a subtype of 'short when 'long outlives 'short. If something lives longer, it can substitute for something that lives shorter. A reference valid for the entire program can fill a slot expecting a reference valid for one function call.

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

fn main() {
    let static_str: &'static str = "I live forever";
    use_reference(static_str);  // 'static substitutes for 'a
}

This works because 'static outlives any 'a the function might demand. The compiler accepts it without complaint. The substitution is safe: if the function expects the reference to live at least as long as 'a, providing one that lives longer can only be safer.

This single subtyping relationship, lifetimes forming a hierarchy based on duration, drives all variance in Rust.

Covariance: preserving the direction

A type constructor is covariant in a parameter when subtyping flows in the same direction. If 'long: 'short (long outlives short), then &'long T can substitute for &'short T. The direction preserves.

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

fn main() {
    let long_lived: &'static str = "static string";
    covariant_example(long_lived);  // Works: &'static str -> &'short str
}

Shared references are covariant in their lifetime parameter. You can always provide a reference that lives longer than required. The function promises to finish using the reference before 'short ends. If the reference actually lives until 'static, that’s fine. The promise remains unbroken.

Covariance also applies to the type parameter in certain positions. Box<T> is covariant in T. Vec<T> is covariant in T. Option<T> is covariant in T. These container types let you substitute subtypes freely.

The diagram shows how the subtyping relationship flows in the same direction. Longer lifetimes at the top, shorter at the bottom. Covariance means the container type follows the same hierarchy.

Contravariance: reversing the direction

Contravariance flips the relationship. If 'long: 'short, then something contravariant in that lifetime goes the opposite direction. In Rust, function parameters exhibit contravariance.

Consider a function type fn(&'a str). This function promises to accept any reference with lifetime 'a. A function that accepts &'short str can be called with references that live at least as long as 'short. But here’s the key insight: a function accepting &'long str makes a stricter promise. It demands references live longer.

fn accepts_short<'short>(_: &'short str) {}
fn accepts_long<'long>(_: &'long str) where 'long: 'static {}

fn takes_callback<'a>(callback: fn(&'a str)) {
    let local = String::from("local");
    callback(&local);
}

If you could substitute accepts_long where accepts_short is expected, you’d have a problem. The callback might be called with a short-lived reference, but accepts_long demands a long-lived one. The substitution fails.

The safe substitution goes the other direction. A function accepting &'short str can substitute for one accepting &'long str. The function that accepts shorter-lived references is more flexible. It can handle anything the stricter function handles, plus more.

The arrows flip. Longer lifetime at the top of the lifetime hierarchy, but the function accepting shorter lifetimes sits at the top of the function type hierarchy. Contravariance reverses the direction.

Invariance: no substitution allowed

Invariance is the strictest rule: no substitution in either direction. The type must match exactly. Mutable references are invariant in their type parameter.

fn invariant_demo<'a>(r: &'a mut String) {
    r.push_str(" world");
}

fn main() {
    let mut s = String::from("hello");
    invariant_demo(&mut s);
}

Why can’t &'a mut T be covariant? Because mutable references allow writing, and writing changes everything.

fn dangerous_if_covariant<'a>(r: &'a mut &'a str) {
    // If this were covariant, we could do something terrible
}

fn would_be_bad() {
    let mut long: &'static str = "static";

    {
        let short_string = String::from("short");
        let short: &str = &short_string;

        // If &mut were covariant, we could assign short to long
        // dangerous_if_covariant(&mut long);
        // long = short;  // long now points to short_string
    }

    // short_string is dropped, but long still references it!
    // println!("{}", long);  // Use after free!
}

The example above shows why invariance matters. If mutable references were covariant in their referent type, you could smuggle a short-lived reference into a location expecting a long-lived one. When the short-lived data goes away, you’d have a dangling reference. Invariance prevents this class of bug entirely.

Cell<T> and RefCell<T> are also invariant in T for the same reason. Any type that allows mutation through shared references must be invariant. The interior mutability pattern requires it.

Why &’a T and &’a mut T differ

The difference between shared and mutable reference variance comes down to capabilities. Shared references only read. Mutable references read and write. This asymmetry in capability creates asymmetry in variance.

Consider what happens when you read through a reference. You extract a value. If that value has a longer lifetime than required, no harm done. The value outlives its use. Covariance is safe for reading.

Consider what happens when you write through a reference. You store a value. If you store a short-lived value where a long-lived one is expected, you create a time bomb. Something else might read that location later, expecting the data to still exist. Covariance would be unsafe for writing.

// This compiles: covariance allows longer lifetime
fn read_example<'a>(r: &'a str) -> &'a str {
    r
}

fn main() {
    let static_str: &'static str = "forever";
    let result: &str = read_example(static_str);  // 'static -> 'a
    println!("{}", result);
}

The read path works because we’re only extracting data. The lifetime can shrink during extraction.

// This won't compile if you try to exploit variance
fn write_example<'a>(r: &'a mut Vec<&'a str>, s: &'a str) {
    r.push(s);
}

fn main() {
    let mut vec: Vec<&'static str> = Vec::new();
    let local = String::from("local");

    // Can't do this: would put &local into vec expecting &'static
    // write_example(&mut vec, &local);
}

The write path requires exact matching. You can’t widen what you’re writing to, because the container might outlive the data you’re inserting.

Variance in generic structs

When you define a generic struct, its variance depends on how type parameters are used. The compiler infers variance from field types.

struct Covariant<'a, T> {
    data: &'a T,  // &'a T is covariant in both 'a and T
}

struct Invariant<'a, T> {
    data: &'a mut T,  // &'a mut T is invariant in T
}

struct Mixed<'a, T> {
    read: &'a T,      // Covariant use
    write: &'a mut T, // Invariant use
}
// Mixed is invariant in T because invariance is the stronger constraint

When a type parameter appears in multiple positions with different variances, the most restrictive wins. If T appears covariantly in one field and invariantly in another, the whole struct is invariant in T. Safety requires the strictest interpretation.

use std::marker::PhantomData;

struct ForceInvariant<T> {
    _marker: PhantomData<fn(T) -> T>,  // Both contravariant and covariant = invariant
}

struct ForceCovariant<T> {
    _marker: PhantomData<T>,  // Covariant
}

struct ForceContravariant<T> {
    _marker: PhantomData<fn(T)>,  // Contravariant
}

PhantomData lets you declare variance explicitly when your struct doesn’t store the type directly. This matters for unsafe code that manages memory manually. The variance declaration tells the compiler what guarantees you’re upholding.

Practical patterns

Understanding variance helps you design better APIs. When you want maximum flexibility in what users can pass, covariance helps. When you need to write generic code over mutable references, you’ll work within invariance constraints.

// This signature is maximally flexible
fn process<'a>(items: &'a [&'a str]) {
    for item in items {
        println!("{}", item);
    }
}

// Callers can use longer-lived data
fn main() {
    let static_items: &[&'static str] = &["one", "two", "three"];
    process(static_items);  // Works: covariance in action
}

When you see lifetime errors in generic code, ask yourself: what variance does the compiler expect? Am I trying to substitute a shorter lifetime where a longer one is required? Am I writing to a location that requires exact matching?

// Common error pattern
fn broken<'a, 'b>(r: &'a mut &'b str, s: &'b str) {
    *r = s;  // Trying to write s into r
}

// This works if lifetimes are properly related
fn fixed<'a, 'b: 'a>(r: &'a mut &'b str, s: &'b str) {
    *r = s;  // 'b outlives 'a, so this is safe
}

The bound 'b: 'a declares that 'b outlives 'a. This makes the substitution safe: we’re writing a longer-lived reference into a location that might be read with a shorter lifetime expectation.

The type system as guardian

Variance isn’t bureaucratic complexity. It’s the type system preventing use-after-free, data races, and undefined behavior. Every variance rule exists because violating it enables real bugs.

Covariance for shared references: safe because reading longer-lived data into shorter-lived contexts never creates dangling references. Contravariance for function parameters: safe because accepting more flexible inputs is always compatible with stricter requirements. Invariance for mutable references: necessary because writing creates obligations that must be met exactly.

The compiler doesn’t explain variance in its error messages. It tells you lifetimes don’t match or types aren’t compatible. But beneath those messages, variance rules determine what matches and what doesn’t. Understanding variance means understanding why the compiler says no.

How variance teaches you to read the borrow checker

Variance demonstrates something profound about Rust’s design philosophy. The language doesn’t trust programmers to track aliasing and mutation mentally. It encodes the rules into the type system and enforces them mechanically. You can’t violate variance without unsafe code, and unsafe code explicitly accepts responsibility for upholding invariants.

The same principles that make variance safe also make concurrent programming safe. If mutable references were covariant, not only could you create dangling references, you could create data races. Two threads could hold mutable references to overlapping lifetimes, both believing they have exclusive access. Invariance prevents this.

Learning variance is learning to think like the borrow checker. You stop fighting the compiler and start predicting its decisions. The mental model you build applies everywhere: function signatures, trait implementations, async code, unsafe abstractions. Variance is the grammar of Rust’s safety guarantees.

Want more like this?

I write regularly about Rust, design patterns, and performance tips. Follow me here on Medium to stay updated.


메타데이터
post_id
cd7f53d701e6
slug
variance-in-rust-explained-cd7f53d701e6
url
https://medium.com/rustaceans/variance-in-rust-explained-cd7f53d701e6
canonical_url
https://medium.com/rustaceans/variance-in-rust-explained-cd7f53d701e6
author_url
https://medium.com/@enzo-lombardi
status
ok
fetched_at
2026-06-26 06:47:43