top of page

Invariance, Covariance and Contravariance : A Clear Explanation

Gulnaz Gurbuz

9 Nis 2025

These concepts describe the conversion capabilities of types. They also determine how generic types behave.

Note: Do not confuse the type conversions mentioned here with polymorphism implementations public class Animal{}

public class Dog:Animal{}

public Animal animal = new Dog(); The snippet I shared above is polymorphism. It has no connection with our concepts. These concepts are meaningful in parameterized types such as collections, generic types and delegates.

Basic Definitions (Don't be intimidated, very detailed explanations are provided below)

Invariance: If a type is invariant, it cannot be used in place of a subtype or supertype.

Covariance:If a type is covariant, a subtype can be used in place of a supertype.

Contravariance: If a type is contravariant, a supertype can be used in place of a subtype.

INVARIANCE

We have aVegetable superclass, and the Tomato and Onion classes derive from it. public class Vegetable{}

public class Tomato : Vegetable{}

public class Onion : Vegetable{} If we were to represent the above hierarchy with a image:

List<Vegetable> veggies = new List<Vegetable>(); ✅

List<Vegetable> veggies = new List<Tomato>(); ❌


The first list created above is a very familiar and ordinary list. The compiler will run it without errors, a perfect choice.


Questioning

But why didn’t the second one work? After all, Tomato is a class derived from Vegetable.

We can do this without any issues:(Quick reminder from above: This is only a polymorphism implementation, not a covariant expression.)


public Vegetable veggy = new Tomato();

If this works, then why can’t we do the same thing with a list?

I think this is a very valid question. Let’s explore what the drawbacks are, what the compiler sees that we don’t, and how it prevents us from making this mistake.


Reasoning

Yes, Tomato 🍅 is a Vegetable 🌱; but List<Tomato> is not List<Vegetable>.

Why?Because the List<T> generic collection is invariant, you cannot use a subtype as the list of its supertype.

Okey but WHY?!Think of it this way: List<Vegetable> veggies = new List<Tomato>();


Let’s assume we created the list this way.

veggies.Add(onion);

The variable veggies is considered a List<Vegetable>, so any type of Vegetable (for example, Onion) can be added.

Later, we wanted to add Onion 🧅 to the veggies 🌱 list and passed Onion as a parameter to the Add method.

But what happens next? The veggies list is actually a List<Tomato> in reality, and we cannot add an Onion to a List<Tomato>.

Since this is not possible, the compiler enforces List<T> as invariant and does not allow type conversions.


COVARIANCE

Due to this feature, we can use a subtype in place of a supertype. This is because covariance allows us to safely retrieve items as their base type but prevents modifying the collection to maintain type safety.

Covariance means we can only read from the collection but not add new elements. If IEnumerable<Vegetable> allowed adding new items, we could accidentally add an Onion to a List<Tomato>, which would violate type safety.

The reason for this is to prevent the issue of adding different types, which we just examined.

In other words, when we are only allowed to access the elements of a collection, there will be no type mismatch problem.

IEnumerable<Vegetable> veggies = new List<Tomato>();
IEnumerable<object> objects = veggies; // Valid due to covariance

Similarly, it can be used in IReadOnlyList and IReadOnlyCollection.

Generic interface

Covariance is achieved in generic interfaces using the out keyword. out can only be used for types that provide data externally (return types). In this way, the interface is essentially made read-only. It cannot modify the T parameter internally; it only uses the returned T value. If you define a method that takes a T parameter, the compiler will generate an error.


public interface IRepository<out T>{

    T GetItem();

}


public class TomatoRepository : IRepository<Tomato>{

    public Tomato GetItem() => new Tomato();

}


class Program{

 static void Main(){

       IRepository<Tomato> tomatoRepo = new TomatoRepository();

        IRepository<Vegetable> vegetableRepo = tomatoRepo; // subtype to supertype

        Vegetable vegetable = vegetableRepo.GetItem();

        Console.WriteLine(vegetable.GetType().Name); // Output: Tomato

    }

}


Covariance with Delegates

A delegate with a supertype can reference a method with a subtype.


delegate Vegetable VegetableFactory();

class Program{


    Tomato CreateTomato() => new Tomato();

    Onion CreateOnion() => new Onion();


    static void Main(){

        VegetableFactory tomatoFactory = CreateTomato;

        VegetableFactory onionFactory = CreateOnion;

        Vegetable tomato = tomatoFactory();

        Vegetable onion = onionFactory();

        Console.WriteLine(tomato.GetType().Name); // Output: Tomato

        Console.WriteLine(onion.GetType().Name); // Output: Onion

    }

}


CONTRAVARIANCE

This feature allows us to use a supertype in place of a subtype. Contravariant structures only accept data as parameters but are not required to store or modify it. Therefore, they do not return the type you pass to them, as they have no such responsibility. They only accept the type you provide as a parameter.


 IComparer<Vegetable> vegetableComparer new VegetableComparer(); 
 IComparer<Tomato> tomatoComparer = vegetableComparer;

Due to contravariance, an IComparer<Vegetable> (which can compare any vegetables) can be used in place of IComparer<Tomato>, since it is still valid for comparing tomatoes.

Generic interface

Contravariance is achieved in generic interfaces using the in keyword. in can only be used for types that are taken as input parameters. The T parameter can only be used internally and cannot be returned. If you create a method that returns a T parameter, the compiler will generate an error.


public class Vegetable {

    public virtual void Process() 

        => Console.WriteLine("Processing Vegetable...");

}

public class Tomato : Vegetable{

    public override void Process() 

        => Console.WriteLine("Processing Tomato...");

}

public interface IProcessor<in T>{

    void ProcessItem(T item);

}


public class VegetableProcessor : IProcessor<Vegetable>{

    public void ProcessItem(Vegetable item){

        item.Process();

    }

}


class Program{

    static void Main(){

        IProcessor<Vegetable> vegetableProcessor = new VegetableProcessor();

        IProcessor<Tomato> tomatoProcessor = vegetableProcessor;

        tomatoProcessor.ProcessItem(new Tomato()); //✅ Valid, output: Processing Tomato...

      // However, we cannot pass the superclass here! (because the expected type is Tomato)

      // tomatoProcessor.ProcessItem(new Vegetable()); // ❌ ERROR!

    }

}


Contravariant ElementsUsed for parameter types. (IComparer<T>, Action<T>, ILogger<T>)



Contravariance with Delegates

Delegates can be contravariant in their parameter type (using in).In other words, a delegate with a subtype can reference a method that takes a supertype as a parameter.


public delegate void ProcessVegetable(Vegetable v);


class Program{

    static void ProcessTomato(Tomato t) => Console.WriteLine("Processing Tomato");

    static void Main(){

        // Due to contravariance, ProcessTomato can be assigned to ProcessVegetable.

        ProcessVegetable vegetableProcessor = ProcessTomato;

        vegetableProcessor(new Tomato()); // Output: Processing Tomato

    }

}


This might feel like assigning a subtype to a supertype, but delegates operate based on a specific principle. Delegates are normally immutable objects. Therefore, even if you use +=, a new method is not added to the existing reference. Instead, a new delegate object is created, and the new method is added to it.

When you assign a method to a delegate, what actually happens in the background is that a delegate object matching the specified signature is created. This object then points to the ProcessTomato method.

In other words, we are actually creating an object of the supertype and making it point to the subtype. This is a characteristic that allows delegates to be contravariant.



Contravariance with Dependency Injection

Contravariance makes it easier to accept more general types in dependency injection.


public interface ILogger<in T>{

    void Log(T message);

}


public class ConsoleLogger<T> : ILogger<T>{

    public void Log(T message){

        Console.WriteLine($"Log: {message}");

    }

}


class Program{

    static void Main(){

        ILogger<object> objectLogger = new ConsoleLogger<object>();

        // Due to contravariance, ILogger<object> can be used as ILogger<string>.

        ILogger<string> stringLogger = objectLogger;

        stringLogger.Log("Hello, Contravariance!"); // Output: Log: Hello, Contravariance!

    }

}


Questioning

Can a contravariant list exist?

Reasoning

No, it is not possible to define a contravariant (in T) list in C#.

The main reason is that contravariance is only supported for types used as input parameters, while collections perform both read and write operations.

In other words, we expect a collection like a list to return the data it holds whenever we need it. However, a contravariant structure does not return a value.

The reason for this is that it is not type-safe — we cannot precisely determine what value it would return.

If contravariance were possible, let’s see what we would encounter in the example below.


List<Vegetable> vegetables = new List<Vegetable>();

List<Tomato> tomatoes = vegetables; //It would be valid if contravariance were allowed

tomatoes.Add(new Tomato()); // Works

tomatoes.Add(new Onion()); // But in reality, an Onion is being added to the Vegetable list!

Tomato firstTomato = tomatoes[0]; // If the first element is Onion, a type error will occur!


Questioning

What happens if we pass a subtype to a contravariant structure?

Reasoning

Let’s examine the following code: IComparer<Tomato> tomatoComparer = new TomatoComparer();IComparer<Vegetable> vegComparer = tomatoComparer; // ❌ If this were possible... Now we have a variable named vegComparer, and its type appears to be IComparer<Vegetable>. However, it actually contains an IComparer<Tomato> instance.

If we try to use this situation as follows:


vegComparer.Compare(new Onion(), new Onion()); 

🚨 DANGER! Onion is not a Tomato!

Here, the expected behavior is to compare Vegetable objects, but in reality, we only have an instance that can compare Tomato.

In this case, an Onion object would be processed by TomatoComparer, which is incorrect! The compiler prevents this contradiction by disallowing this conversion.

In this writing, I tried to explain these concepts. I hope it was helpful. Thank you for reading!


bottom of page