LanguagesType Systems Deep DiveCovariance & Contravariance

Covariance & Contravariance

Type Systems Deep Dive

Covariance and Contravariance: Advanced Type Relationships

In a type system with inheritance, Dog is a subtype of Animal. But what does that mean for containers of these types? Is a List<Dog> a subtype of List<Animal>?

The answer to this question is governed by variance. Variance defines how subtyping relationships between simple types (like Dog and Animal) apply to more complex types built from them (like List<Dog> and List<Animal>).

This is an advanced topic, and a clear explanation in an interview demonstrates a deep understanding of type systems.

The three types of variance are:

  1. Covariance: Preserves the subtyping relationship.
  2. Contravariance: Reverses the subtyping relationship.
  3. Invariance: Does not preserve the subtyping relationship.

Let's assume we have the following class hierarchy: Dog is a subtype of Animal. Cat is a subtype of Animal.


1. Covariance ("Co" = with, preserved)

A type constructor is covariant if it preserves the subtyping order.

If List<T> is covariant, then because Dog is a subtype of Animal, List<Dog> is a subtype of List<Animal>.

This feels intuitive. You can treat a list of dogs as a list of animals.

Where is this safe? Covariance is safe for types that are "producers" or sources of data—things you only read from.

Example: An array of animals. If you have a function that reads from a list of animals, it's perfectly safe to pass it a list of dogs.

// In C#, arrays are covariant by default.
// And IEnumerable<T> is a covariant interface (defined as IEnumerable<out T>)
void PrintAnimalNames(IEnumerable<Animal> animals) {
    foreach (Animal a in animals) {
        Console.WriteLine(a.Name);
    }
}

List<Dog> dogs = new List<Dog> { new Dog { Name = "Fido" }, new Dog { Name = "Rex" } };
// This is allowed because IEnumerable<T> is covariant.
// You can safely treat a sequence of Dogs as a sequence of Animals to read from it.
PrintAnimalNames(dogs);

The Danger: Writing to a Covariant Type If a covariant type were also writable, it would break type safety. Consider this hypothetical broken example:

// Java arrays are covariant, which is considered a flaw in its type system.
Animal[] animals = new Dog[3]; // This is allowed. A Dog[] is an Animal[].

// The problem: You can now try to put a Cat into what is actually an array of Dogs.
animals[0] = new Cat(); // Throws ArrayStoreException at RUNTIME.

// The static type of 'animals' is Animal[], so the compiler allows this.
// But the actual runtime type is Dog[], which cannot hold a Cat.
// This breaks type safety.

This is why generic collections in Java and C# (like List<T>) are invariant by default.


2. Contravariance ("Contra" = against, reversed)

A type constructor is contravariant if it reverses the subtyping order.

If Action<T> is contravariant, then because Dog is a subtype of Animal, Action<Animal> is a subtype of Action<Dog>.

This is the most mind-bending of the three. It reverses the intuitive relationship.

Where is this safe? Contravariance is safe for types that are "consumers" of data—things you only write to.

Example: An action to be performed on an animal. Imagine a function that needs an action to perform on a Dog.

void GroomDog(Dog d, Action<Dog> groomingAction)

An Action<Dog> is a function that takes a Dog as an argument (e.g., (Dog d) => d.BrushCoat()).

Now, consider an Action<Animal>, which is a function that can operate on any animal (e.g., (Animal a) => a.Feed()).

Can you pass the Action<Animal> to the GroomDog function, which expects an Action<Dog>? Yes! An action that can handle any animal can certainly handle a dog.

Therefore, Action<Animal> is a valid substitute (a subtype) for Action<Dog>. The subtyping relationship is reversed.

// The Action<T> delegate in C# is contravariant (defined as Action<in T>).
// It represents a method that takes a parameter of type T.

// An action that works on any Animal.
Action<Animal> feedAnimal = (animal) => Console.WriteLine($"Feeding {animal.Name}");

// An action that only works on a Dog.
Action<Dog> brushDog = (dog) => Console.WriteLine($"Brushing {dog.Name}'s coat");

// Now, let's say we have a variable that holds an action for Dogs.
Action<Dog> dogAction;

// We can assign the more general action to the more specific variable.
// This is contravariance.
dogAction = feedAnimal;

// We cannot assign the more specific action to a more general variable.
// Action<Animal> animalAction = brushDog; // COMPILE-TIME ERROR!

// Usage:
Dog myDog = new Dog { Name = "Buddy" };
dogAction(myDog); // This calls the 'feedAnimal' logic, which is perfectly safe.

3. Invariance ("In" = not)

A type constructor is invariant if the subtyping relationship is not preserved in any direction.

If List<T> is invariant, then even though Dog is a subtype of Animal, List<Dog> is not a subtype of List<Animal>, and List<Animal> is not a subtype of List<Dog>. They are two completely unrelated types.

Where is this safe? Invariance is the safest default. It applies to types that are both producers and consumers (readable and writable). This prevents the type safety issues seen in the covariant array example.

Example: A standard mutable list. List<T> in Java and C# is invariant.

// List<Dog> is NOT a subtype of List<Animal>.
List<Dog> dogs = new ArrayList<>();
// List<Animal> animals = dogs; // COMPILE-TIME ERROR!

// And List<Animal> is NOT a subtype of List<Dog>.
List<Animal> animals2 = new ArrayList<>();
// List<Dog> dogs2 = animals2; // COMPILE-TIME ERROR!

This prevents the ArrayStoreException problem at compile time. If you want to add dogs from a List<Dog> to a List<Animal>, you must do it explicitly: animals.addAll(dogs);.

Summary for Interviews (PECS Principle)

A great way to remember this is the PECS principle, coined by Joshua Bloch: Producer Extends, Consumer Super.

  • Producer Extends (Covariance): If a generic type is a producer (you only get values out of it), you can use extends (in Java) or out (in C#) to make it covariant. List<? extends Animal> can hold a List<Dog>.
  • Consumer Super (Contravariance): If a generic type is a consumer (you only put values in to it), you can use super (in Java) or in (in C#) to make it contravariant. Comparator<? super Dog> can hold a Comparator<Animal>.
  • Invariance: If a type is both a producer and a consumer (readable and writable), it should be invariant. This is the default for collections like List<T>.
VarianceRelationshipSafe For...Keyword (Java Wildcards)
CovarianceList<Dog> is a List<Animal>Producers (Read-Only)? extends Animal
ContravarianceAction<Animal> is a Action<Dog>Consumers (Write-Only)? super Dog
InvarianceList<Dog> and List<Animal> are unrelatedRead/Write CollectionsList<Animal>