Variance: Covariance, Contravariance, Invariance
Variance is one of those type system concepts that developers encounter constantly but rarely name explicitly. Every time you've wondered why you can't assign a `List<String>` to a `List<Object>` in...
Key Insights
- Variance determines how subtyping relationships between generic types relate to their type parameters—understanding it prevents runtime type errors and enables more flexible APIs
- The Producer/Consumer principle (PECS) provides a practical heuristic: use covariance (
out/extends) when you only read values, contravariance (in/super) when you only write values, and invariance when you do both - Function types follow a specific pattern: contravariant in parameters, covariant in return types—this directly derives from the Liskov Substitution Principle
Introduction to Type Variance
Variance is one of those type system concepts that developers encounter constantly but rarely name explicitly. Every time you’ve wondered why you can’t assign a List<String> to a List<Object> in Java, or why Kotlin distinguishes between List and MutableList, you’ve bumped into variance.
At its core, variance answers a simple question: if Child is a subtype of Parent, what’s the relationship between Container<Child> and Container<Parent>? The answer isn’t automatic—it depends on how the container uses its type parameter.
Understanding variance matters for two practical reasons. First, it prevents subtle runtime errors that slip past the compiler. Second, it enables you to write more flexible generic APIs. Without variance annotations, you’d either sacrifice type safety or write redundant code for every type hierarchy.
Invariance: The Default Behavior
Invariance means no subtype relationship exists between Container<Child> and Container<Parent>, regardless of the relationship between Child and Parent. Most languages default to invariance for generic types, and there’s good reason for this conservatism.
Consider why Java’s generic List is invariant:
// This does NOT compile, and that's a good thing
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // Compile error!
// If it compiled, we could do this:
objects.add(42); // Adding an Integer to a "List of Objects"
String s = strings.get(0); // Runtime crash! Integer isn't a String
If List<String> were a subtype of List<Object>, you could add any object to what’s actually a list of strings. The compiler can’t track that objects and strings reference the same list, so it conservatively rejects the assignment entirely.
This is why mutable collections are almost always invariant. The moment you can both read and write through a generic type, allowing variance in either direction creates a type hole.
Java arrays, designed before generics, made the opposite choice—and it was a mistake:
String[] strings = new String[1];
Object[] objects = strings; // Compiles! Arrays are covariant
objects[0] = 42; // Compiles! But throws ArrayStoreException at runtime
Arrays carry runtime type information to catch this, but it’s a band-aid over a design flaw. Generic collections learned from this mistake.
Covariance: Preserving the Subtype Direction
Covariance preserves the subtyping direction: if Child extends Parent, then Container<Child> is a subtype of Container<Parent>. This is safe when the container only produces values—you read from it but never write.
Different languages express covariance differently:
// Kotlin: 'out' modifier declares covariance
interface Producer<out T> {
fun produce(): T
// fun consume(item: T) // Compiler error! Can't use T in 'in' position
}
val stringProducer: Producer<String> = object : Producer<String> {
override fun produce() = "hello"
}
val anyProducer: Producer<Any> = stringProducer // Legal! Covariance
// C#: 'out' keyword in interface definition
public interface IEnumerable<out T> {
IEnumerator<T> GetEnumerator();
}
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // Legal due to covariance
// Scala: '+' prefix on type parameter
trait Producer[+T] {
def produce: T
}
val stringProducer: Producer[String] = () => "hello"
val anyProducer: Producer[Any] = stringProducer // Legal
The safety guarantee is straightforward: if you can only get values out, and you expect a Parent, receiving a Child is always safe (since Child is a Parent). You can treat a producer of cats as a producer of animals because every cat is an animal.
Contravariance: Reversing the Subtype Direction
Contravariance reverses the subtyping direction: if Child extends Parent, then Container<Parent> is a subtype of Container<Child>. This seems counterintuitive until you consider consumers—types that only accept values.
// Kotlin: 'in' modifier declares contravariance
interface Consumer<in T> {
fun consume(item: T)
// fun produce(): T // Compiler error! Can't use T in 'out' position
}
val anyConsumer: Consumer<Any> = object : Consumer<Any> {
override fun consume(item: Any) = println(item)
}
val stringConsumer: Consumer<String> = anyConsumer // Legal! Contravariance
// Java: Comparator is contravariant in practice
Comparator<Object> objectComparator = (a, b) -> a.hashCode() - b.hashCode();
Comparator<? super String> stringComparator = objectComparator; // Legal
List<String> strings = Arrays.asList("c", "a", "b");
strings.sort(stringComparator); // Works perfectly
// C#: 'in' keyword for contravariance
public interface IComparer<in T> {
int Compare(T x, T y);
}
IComparer<object> objectComparer = Comparer<object>.Default;
IComparer<string> stringComparer = objectComparer; // Legal due to contravariance
The logic: if something can consume any Parent, it can certainly consume a Child (which is just a special case of Parent). A function that processes any animal can process cats specifically.
The Producer/Consumer Principle (PECS)
Java developers know this as PECS: Producer Extends, Consumer Super. It’s a practical mnemonic for choosing wildcard bounds:
- Use
? extends Twhen you only read (produce) values of typeT - Use
? super Twhen you only write (consume) values of typeT - Use plain
Twhen you do both
Here’s a utility method demonstrating both:
public static <T> void copyTransformed(
List<? extends T> source, // Producer: we read from it
List<? super T> destination, // Consumer: we write to it
Function<? super T, ? extends T> transformer // Consumes T, produces T
) {
for (T item : source) {
T transformed = transformer.apply(item);
destination.add(transformed);
}
}
// Usage: copy Integers to a Number list with transformation
List<Integer> integers = List.of(1, 2, 3);
List<Number> numbers = new ArrayList<>();
copyTransformed(integers, numbers, n -> n * 2);
Without these wildcards, you’d need exact type matches. With them, you get flexibility: the source can be any list of T or its subtypes, and the destination can be any list of T or its supertypes.
Kotlin and Scala encode this directly in type definitions with out and in, so you don’t need wildcards at call sites—the variance is declared once on the class.
Variance in Function Types
Function types have a specific variance pattern that falls directly out of the Liskov Substitution Principle: contravariant in parameter types, covariant in return types.
type Animal = { name: string };
type Cat = Animal & { meow(): void };
// Function type: (param) => return
type AnimalToAnimal = (a: Animal) => Animal;
type CatToCat = (c: Cat) => Cat;
type AnimalToCat = (a: Animal) => Cat;
type CatToAnimal = (c: Cat) => Animal;
// Which can substitute for AnimalToAnimal?
let f: AnimalToAnimal;
// AnimalToCat: YES - accepts same input, returns more specific output
const animalToCat: AnimalToCat = (a) => ({ ...a, meow: () => {} });
f = animalToCat; // Legal: covariant return
// CatToAnimal: NO - requires more specific input
const catToAnimal: CatToAnimal = (c) => ({ name: c.name });
// f = catToAnimal; // Error: contravariant parameter violation
// Think about it: if code calls f(someAnimal), but f is actually
// catToAnimal, it might receive a Dog—which doesn't have meow()
The substitution principle requires that a replacement function must:
- Accept at least what the original accepts (same or broader parameter types)
- Return at most what the original returns (same or narrower return type)
This is why (Animal) => Cat can replace (Animal) => Animal—it accepts the same input but promises a more specific output. Callers expecting an Animal back will happily accept a Cat.
Practical Guidelines and Common Pitfalls
When to use each variance:
| Variance | Keyword | Use When | Example |
|---|---|---|---|
| Covariant | out/+/extends |
Only producing/returning T | Iterator<T>, Future<T> |
| Contravariant | in/-/super |
Only consuming/accepting T | Comparator<T>, Consumer<T> |
| Invariant | (default) | Both reading and writing T | MutableList<T>, Array<T> |
Common pitfalls:
-
Mutable covariant containers: Never make a mutable collection covariant. If you can add elements, you need invariance.
-
Variance conflicts in method signatures: If a type parameter appears in both
inandoutpositions, you can’t declare variance at the class level.
// This won't compile with variance annotations
interface Processor<T> {
fun process(input: T): T // T in both positions!
}
// Solution: use-site variance in Kotlin/Java, or separate interfaces
interface Processor<T> {
fun process(input: @UnsafeVariance T): T // Escape hatch, use carefully
}
- Forgetting that arrays are covariant in Java/C#: This legacy behavior causes runtime exceptions. Prefer generic collections.
Refactoring example:
// Before: invariant, inflexible
interface Repository<T> {
fun getAll(): List<T>
fun save(item: T)
}
// After: split by usage pattern
interface ReadRepository<out T> {
fun getAll(): List<T>
}
interface WriteRepository<in T> {
fun save(item: T)
}
interface Repository<T> : ReadRepository<T>, WriteRepository<T>
// Now you can use ReadRepository<Animal> where ReadRepository<Cat> is provided
Variance isn’t just academic—it directly affects API flexibility and type safety. Master the producer/consumer distinction, and you’ll write generic code that’s both safer and more reusable.