Understanding Variance in C#: Covariance, Contravariance, and Invariance

C# generics provide type safety and code reusability, but sometimes strict type checking can make our code less flexible. To solve this problem, C# introduces variance, which defines how generic types relate to each other.

Variance allows us to use a more derived or less derived type than originally specified in a generic interface or delegate.

In C#, variance is divided into three categories:

  • Covariance (out)
  • Contravariance (in)
  • Invariance (default behavior)

Let’s understand each of them with practical examples.


What Problem Does Variance Solve?

Suppose we have the following classes:

public class Animal
{
    public string Name { get; set; }
}

public class Dog : Animal
{
    public string Bark { get; set; }
}

Since Dog inherits from Animal, the following assignment works:

Dog dog = new Dog();

Animal animal = dog;

However, generic types do not behave the same way:

List<Dog> dogs = new List<Dog>();

List<Animal> animals = dogs; // Compilation error

Why?

Because generic collections are invariant by default.

This is where variance comes into the picture.


1. Covariance (out)

Covariance allows us to use a more derived type.

If Dog inherits from Animal, then:

IEnumerable<Dog> → IEnumerable<Animal>

This works because IEnumerable<T> only produces values.

The out keyword is used to declare covariance.

public interface IReadRepository<out T>
{
    T GetById(Guid id);

    IEnumerable<T> GetAll();
}

Now we can write:

IReadRepository<Dog> dogRepository = ...;

IReadRepository<Animal> animalRepository = dogRepository;

This is safe because the repository only returns objects and never accepts them as input.

Real-world Example

Framework interfaces that use covariance:

  • IEnumerable<out T>
  • IEnumerator<out T>
  • IQueryable<out T>

These interfaces only return values.


Why Can We Only Return T?

Consider:

public interface IReadRepository<out T>
{
    void Add(T entity);
}

The compiler will generate an error:

Invalid variance: The type parameter 'T' must be covariantly valid.

Because a covariant interface cannot accept objects.

Rule:

✅ Allowed:

T GetById();

❌ Not allowed:

void Save(T entity);


2. Contravariance (in)

Contravariance allows us to use a less derived type.

The in keyword is used for contravariance.

public interface ICommandHandler<in TCommand>
{
    void Handle(TCommand command);
}

Now:

public class AnimalHandler : ICommandHandler<Animal>
{
    public void Handle(Animal animal)
    {
        Console.WriteLine(animal.Name);
    }
}

We can assign:

ICommandHandler<Animal> animalHandler = new AnimalHandler();

ICommandHandler<Dog> dogHandler = animalHandler;

Why is this safe?

Because anything that can process an Animal can also process a Dog.


Why Can We Only Accept T?

This is invalid:

public interface ICommandHandler<in T>
{
    T Create();
}

A contravariant type cannot return T.

Rule:

✅ Allowed:

void Handle(T item);

❌ Not allowed:

T Get();


3. Invariance (Default Behavior)

By default, generic types in C# are invariant.

List<Dog> dogs = new();
List<Animal> animals = dogs;

The compiler throws an error.

Why?

Imagine this were allowed:

animals.Add(new Cat());

Now the original dogs list would contain a Cat, which breaks type safety.

That is why collections such as:

  • List<T>
  • Dictionary<TKey, TValue>
  • HashSet<T>

are invariant.


Variance in Repository Pattern

In a Clean Architecture project, it is common to separate read and write operations.

Read Repository (Covariant)

public interface IReadRepository<out T>
{
    Task<T?> GetByIdAsync(Guid id);
    Task<IEnumerable<T>> GetAllAsync();
}

Since it only returns data, covariance works perfectly.


Write Repository (Contravariant)

public interface IWriteRepository<in T>
{
    Task AddAsync(T entity);
    Task DeleteAsync(T entity);
}

Since it only accepts data, contravariance is possible.


Combined Repository

This will not work:

public interface IRepository<out T>
{
    Task AddAsync(T entity);
    Task<T> GetByIdAsync(Guid id);
}

Because the same type parameter is used for both input and output.

The compiler forces it to remain invariant:

public interface IRepository<T>
{
    Task AddAsync(T entity);
    Task<T> GetByIdAsync(Guid id);
}


Variance and MediatR

MediatR internally uses variance.

For example:

public interface IRequestHandler<in TRequest, TResponse>
{
    Task<TResponse> Handle(
        TRequest request,
        CancellationToken cancellationToken);
}

Notice:

in TRequest

The request type is contravariant because handlers consume requests.

The response type cannot be covariant here because it is wrapped inside Task<T>.


Important Rules to Remember

Variance TypeKeywordCan Return TCan Accept T
Covarianceout
Contravariancein
InvarianceNone


Summary

Variance is one of the most powerful features of C# generics, but it is often misunderstood.

Understanding covariance, contravariance, and invariance helps you:

  • Design cleaner APIs.
  • Build reusable repositories.
  • Understand MediatR internals.
  • Write more flexible generic code.
  • Avoid type-safety issues.

Whenever you create a generic interface, ask yourself:

  • Does it only return values? → Use out.
  • Does it only accept values? → Use in.
  • Does it do both? → Keep it invariant.

Share Button

Leave a Reply

Your email address will not be published. Required fields are marked *