Understanding variance will help us better reason about behaviors of generic types. Even if you have never defined your own generic types, you have almost certainly interacted with them in everyday programming.
Before we begin, there are a few terms we need to be familiar with.
Generic types #
A type is considered generic if it has placeholders for other types within it. These placeholders are called type parameters. For example, an array is a type which is generic over its element type. You will see this in different forms in different languages. list[T]
in Python; std::vector<T>
in C++; Vec<T>
in Rust; List<T>
in java and so on.
The point of generics is usually to re-use code. You can write code for an array/list/hashmap once and have it support any type of element. In some compiled languages (for example, C++ and Rust), generics also provide great runtime performance because the compiler generates specific implementations for each type — a process known as monomorphization 1.
Subtyping #
This can take many forms. One common form is through inheritance in languages like Java, C++ and Python. A class Y is a subtype of X if Y is a sub-class of X (i.e., Y extends X). This subtyping relationship is written as Y <: X
. Another way to look at subtyping is substitution. If a Y can be used in places where a X is needed, we say Y is a subtype of X. For example, we can use a Cat in places where an Animal is needed. This means Cat is a subtype of Animal, or Cat <: Animal
.
In the case of Rust, there’s no subtyping relationship between any types. This is because Rust doesn’t support inheritance. However, there are subtyping relations between lifetimes. More on this later (skip to the Rust section for it).
Variance #
The concept of variance shows up in the intersection of generics and subtyping.
-
A generic type
Something[T]
is covariant in T ifB
being a subtype ofA
means thatSomething[B]
is a subtype ofSomething[A]
.- If
B <: A
thenSomething[B] <: Something[A]
.
- If
-
A generic type
Something[T]
is contravariant in T ifB
being a subtype ofA
means thatSomething[A]
is a subtype ofSomething[B]
.- If
B <: A
thenSomething[A] <: Something[B]
.
- If
-
A generic type
Something[T]
is invariant in T ifB
being a subtype ofA
does not mean thatSomething[B]
is a subtype ofSomething[A]
. In other words, there is no subtyping relation betweenSomething[A]
andSomething[B]
.- If a
Something[A]
is required, there is no replacement for it. NoSomething[B]
can be provided in place of aSomething[A]
.
- If a
An example #
Let’s understand this with an example in Python. Consider this function:
1def some_func(input: list[int | str]):
2 print("i take int or str", input)
It takes a list of integers or strings, i.e., it can have both ints and strings in the same list. If I try to pass a list of only ints to it,
1int_list: list[int] = [1, 2, 3]
2some_func(int_list)
I get this Python does no type-checking at runtime. So this program will run just fine. To see these errors, you need to use a typechecker like mypy or pyright. You can check out this example on the pyright playground.
11. Argument of type "list[int]" cannot be assigned to parameter "input" of type "list[int | str]" in function "some_func"
2 "list[int]" is not assignable to "list[int | str]"
3 Type parameter "_T@list" is invariant, but "int" is not the same as "int | str"
4 Consider switching from "list" to "Sequence" which is covariant
But this works:
1a_int_or_str: int | str = 5
That means int
can be assigned to int | str
. In other words, int
is a subtype of int | str
.
The error says that list[T]
is invariant on T
. But why? What’s wrong with passing a list[int]
to a list[int | str]
? The reason, in Python, is mutability. A list can be mutated at any point. The function can append a string to the input:
1def some_func(input: list[int | str]):
2 print("i take int or str", input)
3 input.append("hello there")
4
5int_list: list[int] = [1, 2, 3]
6some_func(int_list)
Now the type of int_list
outside will be wrong after this function call! It will no longer be a list[int]
.
The solution for our first function is to use typing.Sequence[T]
, which is covariant. The type error message we saw also hints at this (see the last line).
1from typing import Sequence
2def some_func(input: Sequence[int | str]):
3 print("i take int or str", input)
4
5int_list: list[int] = [1, 2, 3]
6some_func(int_list) # works now
Sequence is covariant because it’s immutable. There’s no append method and if you try to assign something to an index (like input[0] = "a"
), you’ll get a
Note: the input is still a list at runtime. It can be mutated too. The error only shows up during type-checking and using Sequence
does not actually prevent any code from mutating the list.
Mutability is just one reason for changing the variance. There may be other reasons why a type is co/contra/invariant.
A note on multiple type parameters #
In the definition of variance above, we said that “Something[T]
is co/contra/invariant in T
”. Each type parameter of a generic can have different variance. For example, a function type Function[ParameterType, ReturnType]
can be covariant in ReturnType
and contravariant in ParameterType
. We will see examples of such types in the next section.
For generics with only a single type parameter, saying “list is invariant” is equivalent to “list[T] is invariant in T”.
Other languages #
Let’s look at a few examples of co/contra/invariance in various languages.
Python #
We saw example of covariance (typing.Sequence
) and invariance (list
) already. Now let’s look at an example for contravariance.
Consider these functions:
1class Media:
2 ...
3
4class Anime(Media):
5 ...
6
7def watch_media(media: Media):
8 ...
9
10def watch_anime(anime: Anime):
11 ...
Here Anime <: Media
since Anime subclasses Media. Notice here that we don’t really use generic types. So how does variance factor in here?
Generic types show up in the type of the functions. In Python, functions are typed using collections.abc.Callable
2. The following are the types of these functions:
watch_media
isCallable[[Media], None]
: takes one argument of typeMedia
and returns a value of typeNone
.watch_anime
isCallable[[Anime], None]
: takes one argument of typeAnime
and returns a value of typeNone
.
Now let’s consider a function that takes a Callable
as an argument:
1from collections.abc import Callable
2def sit_down(watcher: Callable[[Media], None]):
3 watcher(Media())
Can we do sit_down(watch_media)
? Yes.
But can we do sit_down(watch_anime)
? Nope.
11. Argument of type "(anime: Anime) -> None" cannot be assigned to parameter "watcher" of type "(Media) -> None" in function "sit_down"
2 Type "(anime: Anime) -> None" is not assignable to type "(Media) -> None"
3 Parameter 1: type "Media" is incompatible with type "Anime"
4 "Media" is not assignable to "Anime"
Logically, the watcher
function in sit_down
can get called with any type of Media
, not just Anime
. So we cannot pass a function that assumes it will always get an Anime
. This means Callable[[Anime], None]
is not a subtype of Callable[[Media], None]
. Hence, Callable[[T], ...]
is not covariant in T.
What about doing it the other way around?
1from collections.abc import Callable
2def sit_down_2(watcher: Callable[[Anime], None]):
3 watcher(Anime())
4
5sit_down_2(watch_media)
watch_media
can handle all types of Media
. So it can obviously handle an Anime
. Now we have Callable[[Media], None] <: Callable[[Anime], None]
, even though Anime <: Media
. Hence, Callable[[T], ...]
is contravariant in T.
In fact, Callable
is contravariant in all its argument types. That is, Callable[[A2, B2, C2, ...], ...] <: Callable[[A1, B1, C1, ...], ...]
only if A1 <: A2 and B1 <: B2 and C1 <: C2
and so on.
Typescript #
Unlike Python, arrays in Typescript are covariant! That means the below program is valid and passes the typechecker. This is intentional in the design of Typescript.
1function some_func(input: (number | string)[]) {
2 console.log(input);
3 input.push("hello world");
4}
5
6// supposed to be an array of numbers only
7const some_array: number[] = [1, 2, 3];
8some_func(some_array);
9console.log(some_array); // [1, 2, 3, "hello world"] - oops!
Now let’s see an example for contravariance. Consider these interfaces:
1interface Media {
2 name: string;
3}
4
5interface Anime extends Media {
6 studio: string;
7}
8
9function watchMedia(media: Media) {
10}
11
12function watchAnime(anime: Anime) {
13}
Here Anime <: Media
since the Anime
interface extends the Media
interface. Similar to the Python example, we come across variance in the types of functions. The types of the functions we defined can be written as:
watchMedia
is(media: Media) => void
: takes one argument of typeMedia
and returns nothing.watchAnime
is(anime: Anime) => void
: takes one argument of typeAnime
and returns nothing.
Now consider this function that takes another function as an argument:
1function sitDown(watcher: (media: Media) => void) {
2 watcher({name: "Arcane"});
3}
Can we sitDown(watchMedia)
? Yes.
Can we sitDown(watchAnime)
? Nope.
1Argument of type '(anime: Anime) => void' is not assignable to parameter of type '(media: Media) => void'.
2 Types of parameters 'anime' and 'media' are incompatible.
3 Property 'studio' is missing in type 'Media' but required in type 'Anime'.
Logically, the watcher
function in sitDown
can get called with any type of Media
, not just Anime
. So we cannot pass a function that assumes it will always get an Anime
. This means (anime: Anime) => void
is not a subtype of (media: Media) => void
. Hence, (value: T): void
is not covariant in T.
What about doing it the other way around?
1function sitDown2(watcher: (media: Anime) => void) {
2 watcher({name: "Vinland Saga", studio: "WIT"});
3}
4
5sitDown2(watchMedia);
This is fine because watchMedia
can handle any Media
. So it can obviously handle an Anime
.
Now we have (media: Media) => void <: (anime: Anime) => void
even though Anime <: Media
. This is called contravariance and we can say that (value: T): void
is contravariant in T
.
Invariance in Typescript doesn’t show up very often. If you know of better examples than the one I show here, please let me know.
Consider the following functions:
1function makeSequel(media: Media): Media {
2 return {name: media.name + " 2"};
3}
4
5function makeSequelAnime(anime: Anime): Anime {
6 return {name: anime.name + " 2", studio: anime.studio};
7}
Both of them return a value with the same type as its input. The generic form of such a function would be (value: T) => T
. Now consider the below function that takes such a function as its input:
1function applyTransform(transformer: (media: Media) => Media) {
2 return transformer({name: "Invincible"});
3}
Here, this works just fine:
1applyTransform(makeSequel)
But this fails:
1applyTransform(makeSequelAnime)
With the following error:
1Argument of type '(anime: Anime) => Anime' is not assignable to parameter of type '(media: Media) => Media'.
2 Types of parameters 'anime' and 'media' are incompatible.
3 Property 'studio' is missing in type 'Media' but required in type 'Anime'.
We can say that (value: T) => T
is not covariant in T, because (anime: Anime) => Anime
is not a subtype of (media: Media) => Media
.
Now consider another function that takes an Anime transformer instead:
1function applyAnimeTransform(transformer: (anime: Anime) => Anime) {
2 return transformer({name: "Frieren", studio: "Madhouse"});
3}
Here, this works fine:
1applyAnimeTransform(makeSequelAnime)
But this fails:
1applyAnimeTransform(makeSequel)
With the following error:
1Argument of type '(media: Media) => Media' is not assignable to parameter of type '(anime: Anime) => Anime'.
2 Property 'studio' is missing in type 'Media' but required in type 'Anime'.
(media: Media) => Media
is not a subtype of (anime: Anime) => Anime
, which means that (value: T) => T
is not contravariant in T
. Since it is neither covariant nor contravariant, we can say that (value: T) => T
is invariant in T
.
Rust #
Although Rust does not allow defining our own subtypes like in other languages (since it does not support inheritance), there is yet one subtyping relationship that exists. This relationship is on lifetimes instead of type parameters.
A full explanation of lifetimes would make this post way longer than it already is. I will try explaining them briefly. Lifetimes in Rust are regions of code where a variable is accessible. Consider this example:
1fn main() {
2 let some_var = "hello there".to_owned(); // the lifetime of some_var starts here
3
4 println!("The value of some_var is {}", some_var);
5 // the lifetime of some_var ends at the end of the block implicitly
6}
We can cut short the lifetime of a variable by calling drop
on it:
1
2fn main() {
3 let some_var = "hello there".to_owned(); // the lifetime of some_var starts here
4
5 drop(some_var); // the lifetime of some_var ends here!
6
7 // oops can't use it anymore!
8 println!("The value of some_var is {}", some_var);
9}
For most rust programs, lifetimes become a problem when references are involved. They will often be implicit and unnamed like in &str
, but sometimes we need to name them like in &'a str
. Lifetime names start with apostrophe and are followed by the name. Lifetimes of functions and structs are specified in the same manner as type parameters. In other words, functions and structs can be generic over lifetimes.
Now let’s understand the subtyping relationship between lifetimes. Consider this struct:
1struct Token<'a> {
2 pub session_name: &'a str
3}
This means that a Token
must live at least as long as its session_name
. Now consider a function that creates a token:
1fn create_token_1<'a>(session_name: &'a str) -> Token<'a> {
2 Token { session_name }
3}
Here too, the returned Token
lives at least as long as the session_name
being passed in. Rust’s borrow checker makes sure of this. Let’s create a token and use it:
1let session_name: String = "browser1".to_owned();
2let token = create_token_1(&session_name);
3
4// this works
5println!("I have a token for {}", token.session_name);
Now if we try to drop the session_name
before the usage of token
:
1let session_name: String = "browser1".to_owned();
2let token = create_token_1(&session_name);
3
4// not allowed!
5drop(session_name);
6
7println!("I have a token for {}", token.session_name);
We get an error:
1error[E0505]: cannot move out of `session_name` because it is borrowed
2 --> src/bin/lifetimes.rs:20:10
3 |
417 | let session_name: String = "browser1".to_owned();
5 | ------------ binding `session_name` declared here
618 | let token = create_token_1(&session_name);
7 | ------------- borrow of `session_name` occurs here
819 |
920 | drop(session_name);
10 | ^^^^^^^^^^^^ move out of `session_name` occurs here
1121 |
1222 | println!("I have a token for {}", token.session_name);
13 | ------------------ borrow later used here
14 |
Now let’s revisit the function and try to provide different lifetimes for the input and output:
1fn create_token_2<'a, 'b>(session_name: &'a str) -> Token<'b> {
2 Token { session_name }
3}
This is not allowed. Recall that the definition of Token
takes its lifetime from the lifetime of the inner session_name
. In this case, we’re constructing a Token<'a>
but the return type requires a Token<'b>
. 'a
and 'b
are two lifetimes with no relationship. We can define this relationship explicitly:
1fn create_token_2<'a, 'b>(session_name: &'a str) -> Token<'b>
2where
3 'a: 'b,
4{
5 Token { session_name }
6}
Here 'a: 'b
means that lifetime 'a
is at least as long as lifetime 'b
. In other words, any variable with lifetime 'a
can be used in the place where a lifetime of 'b
is needed. That’s subtyping! So we can write this as 'a <: 'b
. Notice here that 'a
is the longer lifetime compared to 'b
. Generalizing this, any lifetime 'long
which encompasses another lifetime 'short
is a subtype: 'long <: 'short
.
There is a special lifetime in Rust named 'static
. References with a lifetime of 'static
live for the duration of the program. That is, we can assume that they are never dropped. We can say that the 'static
lifetime is longer than all other lifetimes. Or in other words, 'static
is a subtype of all lifetimes.
Now that the relationship is established, let’s look at examples of variance. Actually, we have already looked at covariance. Let’s simplify our previous example a bit:
1fn create_token_3<'a, 'b>(session_name: &'a str) -> &'b str
2where
3 'a: 'b,
4{
5 session_name
6}
Here we are assigning a &'a str
to a &'b str
, which means &'a str <: &'b str
. Since we know 'a <: 'b
, we can say that &'a T
is
&'a T
is also covariant in T
. For example, &'a &'b str
is a subtype of &'a &'c str
if 'b <: 'c
.
in 'a
.
Now consider this example:
1fn example<'long, 'short>(mut some_str: &'long str)
2where
3 'long: 'short,
4{
5 let mut_ref_to_ref_str: &mut &'short str = &mut some_str;
6}
This function defines two lifetimes with the relationship 'long <: 'short
, i.e., 'long
lives at least as long as 'short
. We’re passing in a &str
with lifetime 'long
. The mut_ref_to_ref_str
is a mutable reference to an immutable reference to a str
. In short, it is a mutable reference to a &str
. This means we can’t modify the inner &str
, but we can make mut_ref_to_ref_str
point to another &str
. Sadly, the above function does not compile:
1error: lifetime may not live long enough
2 --> src/bin/lifetimes.rs:42:29
3 |
438 | fn example<'long, 'short>(mut some_str: &'long str)
5 | ----- ------ lifetime `'short` defined here
6 | |
7 | lifetime `'long` defined here
8...
942 | let mut_ref_to_ref_str: &mut &'short str = &mut some_str;
10 | ^^^^^^^^^^^^^^^^ type annotation requires that `'short` must outlive `'long`
11 |
12 = help: consider adding the following bound: `'short: 'long`
13 = note: requirement occurs because of a mutable reference to `&str`
Even though 'long <: 'short
and &'long str <: &'short str
, &mut &'long str
is not a subtype of &mut &'short str
. But why is this not allowed? Let’s see what happens when this is allowed. We would be allowed to use the mutable reference to make mut_ref_to_ref_str
point to a &'short str
:
1fn example<'long, 'short>(mut some_str: &'long str)
2where
3 'long: 'short,
4{
5 let mut_ref_to_ref_str: &mut &'short str = &mut some_str;
6
7 {
8 let short_lived_string: String = "hello there".to_owned();
9 let short_ref: &'short str = &short_lived_string;
10 *mut_ref_to_ref_str = short_ref;
11 // short_lived_string is dropped!
12 }
13
14 // some_str will not point to a &str with a 'short lifetime!
15}
At the end of this function, some_str
is actually assigned to short_lived_string
which is dropped at the end of the inner block. This means that using some_str
after the block is actually a use-after-free! Rust is designed to prevent such memory bugs. So it makes sense that this is not allowed. Hence, we can say that &mut T
is not covariant in T
.
If we reverse the assignment to be:
1fn example<'long, 'short>(mut some_str: &'short str)
2where
3 'long: 'short,
4{
5 let mut_ref_to_ref_str: &mut &'long str = &mut some_str;
6}
This is obviously wrong since 'short
is a shorter lifetime. So &mut T
is not contravariant in T. The conclusion is that &mut T
is invariant in T
.
Now let’s look at a function that takes another function (pointer) as its argument:
1fn sit_down(watcher: fn(&str)) {
2 let anime: String = "vinland saga".to_owned();
3 watcher(&anime);
4}
watcher
is a function that takes in a &str
with some lifetime. We are passing it a temporary, local &str
created within in the sit_down
function.
Now let’s try to pass it a function that takes a &str
with a lifetime that is a subtype. Since 'static
is a subtype of all lifetimes, let’s use that:
1fn static_watcher(name: &'static str) {
2 println!("watching static name {}", name);
3}
4
5fn main() {
6 sit_down(static_watcher);
7}
This does not work:
1error[E0308]: mismatched types
2 --> src/bin/lifetimes.rs:120:14
3 |
4120 | sit_down(static_watcher);
5 | -------- ^^^^^^^^^^^^^^ one type is more general than the other
6 | |
7 | arguments to this function are incorrect
8 |
9 = note: expected fn pointer `for<'a> fn(&'a _)`
10 found fn item `fn(&'static _) {static_watcher}`
static_watcher
can only handle strings with a 'static
lifetime, but within sit_down
, we are passing a local string that has a shorter lifetime. This means fn(&'static str)
is not a subtype of fn(&'a str)
even though 'static <: 'a
. Hence, we can say that fn(&'a T)
is not covariant in 'a
.
What about doing it the other way around?
1fn sit_down_static(watcher: fn(&'static str)) {
2 watcher("vinland saga");
3}
4
5fn any_ref_watcher<'a>(name: &'a str) {
6 println!("any_ref_watcher watching {}", name);
7}
8
9fn main() {
10 sit_down_static(any_ref_watcher);
11}
Here we’re asking for a watcher
that can handle &str
with static lifetimes and we’re passing a function that handles any &'a str
. This compiles. We have fn(&'a) <: fn(&'static)
even though 'static <: 'a
. This means that fn(&'a T)
is contravariant in 'a
.
The Rustonomicon has a table summarizing the variance of some common generic types here.
A note on trait upcasting #
Rust 1.86, which was released in April 2025, introduced trait upcasting. Consider the following traits:
1trait Media {}
2
3trait Anime: Media {}
The Anime: Media
defines a that Media
is a supertrait of Anime
. With 1.86 and above, we can now assign a dyn Anime
trait object to a dyn Media
trait object. For example, this works:
1struct Frieren;
2impl Media for Frieren {}
3impl Anime for Frieren {}
4
5fn main() {
6 let some_anime: Box<dyn Anime> = Box::new(Frieren);
7 let some_media: Box<dyn Media> = some_anime; // this works
8}
This may look like a subtyping relationship, like Anime <: Media
. But nope! It’s not. Rust 1.86 does not introduce a new subtyping relationship. Lifetimes are still the only subtypes. Further, Box<T>
may look like it’s covariant in T
. This is just coercion and does not involve variance. For more detailed explanations, see the responses to this discussion on the Rust Internals Forum.
I hope this blog post helped explain some behavior of type systems through the understanding of variance. Please reach out to me directly for any feedback. Thank you for reading!
Acknowledgements: Thank you, Sathvik Srinivas, for proofreading and suggesting improvements to this post.
-
Interestingly, even though Java is a compiled language, it does not do monomorphization. After compilation, a type parameter in a generic class is replaced with
object
. Thisobject
type can store any object in Java. But more importantly, primitives (int, long, float, char, etc.) are not objects. So you can’t really have a fastArray<int>
in Java. You can have anArray<Integer>
, but values ofInteger
types take 4x more memory (16 bytes) compared toint
s (4 bytes). So a genericArray
in Java will always be slower than specific arrays for each type (anArrayInt
for example). ↩︎ -
Yes, there is a module in the Python standard library named
abc
. In fact, there are two. There’s theabc
module that helps you define Abstract Base Classes (hence ABC). Then there’scollections.abc
. ↩︎