Home C# / .NET Covariance and Contravariance in C# Explained — With Real-World Examples and Gotchas

Covariance and Contravariance in C# Explained — With Real-World Examples and Gotchas

In Plain English 🔥
Imagine you have a basket labeled 'Fruit'. You can put apples in it because an apple IS a fruit — that's covariance, flowing in the same direction as inheritance. Now imagine a machine that processes any fruit. You can use that machine to process apples too, even though it was built for fruit in general — that's contravariance, flowing in the opposite direction. Covariance lets you use a more specific type where a more general one is expected (reading). Contravariance lets you use a more general handler where a specific one is expected (writing).
⚡ Quick Answer
Imagine you have a basket labeled 'Fruit'. You can put apples in it because an apple IS a fruit — that's covariance, flowing in the same direction as inheritance. Now imagine a machine that processes any fruit. You can use that machine to process apples too, even though it was built for fruit in general — that's contravariance, flowing in the opposite direction. Covariance lets you use a more specific type where a more general one is expected (reading). Contravariance lets you use a more general handler where a specific one is expected (writing).

Generic type safety is one of the most quietly powerful features of C#, and covariance and contravariance sit right at its heart. Every time you assign an IEnumerable to an IEnumerable, or pass a Func where a Func is expected, the CLR is doing something subtle and deliberate on your behalf. Most developers use these features daily without knowing their names — and that gap in knowledge causes real production bugs.

The problem these concepts solve is deceptively simple: how do you make generic types work safely with inheritance hierarchies? Without covariance and contravariance, you'd have to write casting boilerplate everywhere, or worse, end up with runtime InvalidCastExceptions that slip past the compiler. The C# type system gives you a way to declare, at the interface or delegate level, exactly which direction type substitution is safe — and the compiler enforces it.

By the end of this article you'll understand exactly what the 'in' and 'out' keywords do on generic type parameters, why arrays in C# are covariant but carry a runtime cost, how delegate variance works without any keyword annotation, where variance is intentionally unsupported and why, and how to apply all of this in production code with confidence. You'll also be ready to answer the variance questions that trip up experienced developers in senior-level interviews.

The Type Substitution Problem — Why Variance Exists at All

Start with the Liskov Substitution Principle: a Dog can be used anywhere an Animal is expected. That's normal polymorphism and it works perfectly for simple reference types. The trouble starts with generics.

Consider List and List. A Dog IS-AN Animal, so you might assume List IS-A List. But it isn't — and for very good reason. If it were, you could write code that adds a Cat to what the runtime knows is a List, causing a type explosion the compiler can't catch. This is why List is invariant: no substitution in either direction is allowed.

Variance solves this by being surgical. Instead of making List magically accept subtypes, C# lets interface and delegate designers mark individual type parameters as safe for covariant use (out — you only return T, never accept it) or safe for contravariant use (in — you only accept T, never return it). The compiler then verifies those contracts are upheld everywhere inside the type, making the variance provably safe at compile time, not a runtime gamble.

This is the key insight most articles skip: variance isn't magic permissiveness. It's a compile-time-verified contract about data flow direction.

VarianceMotivation.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
using System;
using System.Collections.Generic;

public class Animal
{
    public string Name { get; init; }
    public Animal(string name) => Name = name;
    public override string ToString() => $"Animal({Name})";
}

public class Dog : Animal
{
    public Dog(string name) : base(name) { }
    public override string ToString() => $"Dog({Name})";
}

public class Cat : Animal
{
    public Cat(string name) : base(name) { }
    public override string ToString() => $"Cat({Name})";
}

class VarianceMotivation
{
    static void Main()
    {
        // --- WHY List<Dog> cannot be List<Animal> ---
        List<Dog> dogPack = new() { new Dog("Rex"), new Dog("Buddy") };

        // This line does NOT compile — List<T> is INVARIANT.
        // If it did compile, the next line would corrupt the list at runtime.
        // List<Animal> animals = dogPack;  // CS0029 — cannot implicitly convert
        // animals.Add(new Cat("Whiskers")); // Rex and Buddy would be very upset

        // --- BUT IEnumerable<Dog> CAN be used as IEnumerable<Animal> ---
        // IEnumerable<T> is COVARIANT (out T), so this is safe and legal.
        // We can only READ from IEnumerable — we can never Add to it.
        // Therefore the Cat-corruption scenario is impossible.
        IEnumerable<Animal> safeAnimals = dogPack; // compiles just fine

        Console.WriteLine("Safe covariant assignment succeeded.");
        foreach (Animal animal in safeAnimals)
        {
            // Each element is still a Dog at runtime — covariance preserves identity
            Console.WriteLine($"  {animal} — runtime type: {animal.GetType().Name}");
        }
    }
}
▶ Output
Safe covariant assignment succeeded.
Dog(Rex) — runtime type: Dog
Dog(Buddy) — runtime type: Dog
🔥
The Direction Rule:Think of 'out' as a read-only outlet — data flows OUT of the type to the caller, so widening is safe. Think of 'in' as a write-only inlet — data flows IN from the caller, so widening to a more general handler is safe. If data flows in both directions, the type parameter must be invariant.

Covariance With 'out' — Building and Using Covariant Interfaces

Covariance is declared with the 'out' keyword on a type parameter in an interface or delegate definition. Once you mark a type parameter as 'out', the compiler enforces a strict rule: T can only appear in output positions — return types, property getters, and out parameters. It cannot appear as a method parameter type. This restriction is what makes the covariant assignment safe.

The canonical example in the BCL is IEnumerable. Because the interface only ever produces T values (via MoveNext/Current), it's provably impossible to inject a wrong-typed object through it. The compiler verifies this every time you implement the interface too — if you try to put 'out T' in a method parameter, you'll get CS1961 immediately.

Where this matters in real code: factory results, read-only projections, producer patterns. Any time you have an interface that returns objects of type T but never accepts T as input, mark that parameter as 'out' and you unlock free assignment compatibility across your entire inheritance hierarchy without a single cast.

One nuance worth burning into memory: covariance only works on interfaces and delegates, never on classes. List will never be assignable to List regardless of what you do, because List is a class. This is by design — classes have mutable state that makes variance unsound.

CovariantProducer.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
using System;
using System.Collections.Generic;

// --- Covariant interface: T only flows OUT ---
// The 'out' keyword tells the compiler: this interface only produces T values.
// It can never accept a T as an argument, so widening is provably safe.
public interface IAnimalProducer<out TAnimal> where TAnimal : Animal
{
    TAnimal Produce();         // Legal: T in return position (output)
    IEnumerable<TAnimal> ProduceBatch(int count); // Legal: T inside a covariant type
    // void Accept(TAnimal animal); // ILLEGAL — CS1961, T in input position
}

public class DogBreeder : IAnimalProducer<Dog>
{
    private readonly string[] _names = { "Apollo", "Bella", "Caesar" };
    private int _index = 0;

    public Dog Produce() => new Dog(_names[_index++ % _names.Length]);

    public IEnumerable<Dog> ProduceBatch(int count)
    {
        for (int i = 0; i < count; i++)
            yield return Produce();
    }
}

public class RescueShelter : IAnimalProducer<Cat>
{
    public Cat Produce() => new Cat("Rescue-" + Guid.NewGuid().ToString()[..4]);

    public IEnumerable<Cat> ProduceBatch(int count)
    {
        for (int i = 0; i < count; i++)
            yield return Produce();
    }
}

class CovariantProducer
{
    // This method accepts any IAnimalProducer<Animal>.
    // Because of covariance, we can pass an IAnimalProducer<Dog> or IAnimalProducer<Cat>.
    static void DisplayThreeAnimals(IAnimalProducer<Animal> producer)
    {
        Console.WriteLine($"Producer type: {producer.GetType().Name}");
        foreach (Animal animal in producer.ProduceBatch(3))
        {
            // Runtime type is preserved — covariance doesn't erase it
            Console.WriteLine($"  Got: {animal} [{animal.GetType().Name}]");
        }
    }

    static void Main()
    {
        IAnimalProducer<Dog> dogBreeder = new DogBreeder();
        IAnimalProducer<Cat> rescueShelter = new RescueShelter();

        // Covariant assignment: IAnimalProducer<Dog> → IAnimalProducer<Animal>
        // This compiles ONLY because TAnimal is marked 'out'
        IAnimalProducer<Animal> animalSource = dogBreeder;

        DisplayThreeAnimals(dogBreeder);     // passes directly
        DisplayThreeAnimals(rescueShelter);  // Cat producer accepted as Animal producer
        DisplayThreeAnimals(animalSource);   // the explicitly widened reference
    }
}
▶ Output
Producer type: DogBreeder
Got: Dog(Apollo) [Dog]
Got: Dog(Bella) [Dog]
Got: Dog(Caesar) [Dog]
Producer type: RescueShelter
Got: Animal(Rescue-a3f1) [Cat]
Got: Animal(Rescue-b72c) [Cat]
Got: Animal(Rescue-e9d4) [Cat]
Producer type: DogBreeder
Got: Dog(Apollo) [Dog]
Got: Dog(Bella) [Dog]
Got: Dog(Caesar) [Dog]
⚠️
Pro Tip — LINQ relies on this:Every LINQ extension method that takes IEnumerable works with IEnumerable, IEnumerable, etc., partly because of IEnumerable's covariance. When you design a read-only producer interface in your own codebase, add 'out' to the type parameter — you'll save every caller a cast and make the API composable with LINQ for free.

Contravariance With 'in' — When a General Handler Beats a Specific One

Contravariance is the mirror image and the one that confuses developers most, because it feels backwards. The 'in' keyword on a type parameter means T can only appear in input positions — method parameters and property setters. It cannot appear in return types. This makes the assignment direction flip: a more general type can be assigned to a more specific one.

The classic BCL example is IComparer. An IComparer that compares any two animals by name can safely be used as an IComparer, because every Dog is an Animal — the comparer will work correctly on any Dog pair. You don't need a separate DogComparer just because your sort method expects IComparer.

Where this matters in production: event handlers, comparers, validators, formatters, loggers — anything that consumes a value rather than producing it. If your interface only ever accepts T as input and never returns it, mark T as 'in' and you get assignment compatibility in the useful direction: callers can supply a broader handler and it just works.

The real power shows up when you combine covariance and contravariance in the same pipeline. A function that accepts a broad input type and returns a narrow output type composes beautifully across inheritance boundaries — which is exactly what Func expresses in the BCL.

ContravariantProcessor.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
using System;
using System.Collections.Generic;

// --- Contravariant interface: T only flows IN ---
// The 'in' keyword means this interface only CONSUMES T values.
// Therefore a handler of Animal can safely act as a handler of Dog.
public interface IAnimalProcessor<in TAnimal> where TAnimal : Animal
{
    void Process(TAnimal animal);          // Legal: T in parameter (input position)
    void ProcessBatch(IEnumerable<TAnimal> batch); // Legal: IEnumerable<TAnimal> is input
    // TAnimal Retrieve(); // ILLEGAL — CS1961, T in output position
}

// A general processor that handles ANY animal
public class AnimalHealthChecker : IAnimalProcessor<Animal>
{
    public void Process(Animal animal)
    {
        // Works on any Animal — so it works on Dog and Cat too
        Console.WriteLine($"  Health check passed for {animal} [{animal.GetType().Name}]");
    }

    public void ProcessBatch(IEnumerable<Animal> batch)
    {
        foreach (Animal animal in batch)
            Process(animal);
    }
}

// A specific processor that only handles Dogs
public class DogTrainer : IAnimalProcessor<Dog>
{
    public void Process(Dog dog)
    {
        Console.WriteLine($"  Training session for {dog}");
    }

    public void ProcessBatch(IEnumerable<Dog> batch)
    {
        foreach (Dog dog in batch)
            Process(dog);
    }
}

class ContravariantProcessor
{
    // This method needs something that processes Dogs specifically
    static void RunDogPipeline(IAnimalProcessor<Dog> dogProcessor, IEnumerable<Dog> dogs)
    {
        Console.WriteLine($"  Using processor: {dogProcessor.GetType().Name}");
        dogProcessor.ProcessBatch(dogs);
    }

    static void Main()
    {
        var dogPack = new List<Dog> { new Dog("Max"), new Dog("Luna") };

        IAnimalProcessor<Animal> generalChecker = new AnimalHealthChecker();
        IAnimalProcessor<Dog> specificTrainer = new DogTrainer();

        // Contravariant assignment: IAnimalProcessor<Animal> → IAnimalProcessor<Dog>
        // Flows OPPOSITE to inheritance: Animal is broader than Dog,
        // yet the Animal processor is assignable to the Dog processor slot.
        // Safe because: anything the Dog processor is asked to handle IS an Animal.
        IAnimalProcessor<Dog> checkerAsDogProcessor = generalChecker; // compiles!

        Console.WriteLine("Running with specific DogTrainer:");
        RunDogPipeline(specificTrainer, dogPack);

        Console.WriteLine("Running with general AnimalHealthChecker (contravariant):");
        RunDogPipeline(generalChecker, dogPack);   // general processor passed directly
        RunDogPipeline(checkerAsDogProcessor, dogPack); // explicit contravariant reference
    }
}
▶ Output
Running with specific DogTrainer:
Using processor: DogTrainer
Training session for Dog(Max)
Training session for Dog(Luna)
Running with general AnimalHealthChecker (contravariant):
Using processor: AnimalHealthChecker
Health check passed for Dog(Max) [Dog]
Health check passed for Dog(Luna) [Dog]
Running with general AnimalHealthChecker (contravariant):
Using processor: AnimalHealthChecker
Health check passed for Dog(Max) [Dog]
Health check passed for Dog(Luna) [Dog]
⚠️
Watch Out — Contravariance Reverses Your Intuition:With contravariance, the assignment goes against the inheritance arrow: IProcessor is assignable to IProcessor, even though Dog derives from Animal. This is correct and intentional. If you find yourself fighting the compiler on this, sketch the data flow: ask 'does T come IN or go OUT?' and the right keyword will follow.

Delegate Variance, Array Covariance, and the Hidden Runtime Cost

Delegates in C# support variance automatically — you don't even need to declare 'in' or 'out' on them for method group conversions. A method that returns a Dog can be assigned to a Func delegate variable (covariance), and a method that accepts an Animal can be assigned to an Action (contravariance). This works for method group assignments specifically, not for delegate type assignments between different constructed generic delegate types.

Array covariance is a different and older story — and a problematic one. string[] is assignable to object[] in C# because arrays have been covariant since C# 1.0, predating generics. This was a pragmatic decision (Java made the same one), but it's unsound: you can store any object reference in the object[] variable and the compiler won't stop you. The runtime catches it with an ArrayTypeMismatchException, but that's a runtime failure, not a compile-time one — exactly the kind of bug you want the type system to prevent.

The key difference from IEnumerable is that arrays are mutable. You can write to an array through the widened reference, which is what creates the danger. IEnumerable avoids this by being read-only by design. This is why generic covariance only works on interfaces and delegates, not on classes or arrays.

The performance angle: every write to an array that was assigned to a wider element-type array goes through a CLR runtime check called a 'covariant array store check'. This is a non-trivial cost in tight loops. Prefer IReadOnlyList or IEnumerable over raw array covariance in performance-sensitive paths.

DelegateAndArrayVariance.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
using System;

class DelegateAndArrayVariance
{
    // --- Method group covariance ---
    // Returns Dog (more specific), but compatible with Func<Animal> (more general)
    static Dog CreateDog() => new Dog("Scout");

    // --- Method group contravariance ---
    // Accepts Animal (more general), but compatible with Action<Dog> (more specific)
    static void LogAnimal(Animal animal)
        => Console.WriteLine($"  [LOG] {animal} ({animal.GetType().Name})");

    static void Main()
    {
        // DELEGATE COVARIANCE — method group assignment
        // CreateDog returns Dog; Dog IS-AN Animal; so Func<Animal> can hold it.
        Func<Animal> animalFactory = CreateDog;
        Animal produced = animalFactory();
        Console.WriteLine($"Delegate covariance produced: {produced}");

        // DELEGATE CONTRAVARIANCE — method group assignment
        // LogAnimal accepts Animal; Dog IS-AN Animal;
        // so a method handling any Animal can handle Dogs specifically.
        Action<Dog> dogLogger = LogAnimal;
        dogLogger(new Dog("Ranger"));

        // --- ARRAY COVARIANCE — legal but dangerous ---
        Dog[] dogArray = { new Dog("Fido"), new Dog("Spot") };

        // Compiles fine — arrays are covariant since C# 1.0
        Animal[] animalArray = dogArray; // same underlying array in memory!

        Console.WriteLine("\nReading through widened array reference (safe):");
        foreach (Animal a in animalArray)
            Console.WriteLine($"  {a}");

        // WRITING through the widened reference — runtime ArrayTypeMismatchException!
        Console.WriteLine("\nAttempting unsafe array write...");
        try
        {
            // The compiler allows this because animalArray is Animal[].
            // But at runtime, the CLR checks the actual element type (Dog[])
            // and rejects Cat — this is the 'covariant array store check'.
            animalArray[0] = new Cat("Mittens"); // ArrayTypeMismatchException here
        }
        catch (ArrayTypeMismatchException ex)
        {
            Console.WriteLine($"  Runtime caught it: {ex.GetType().Name}");
            Console.WriteLine("  This is why array covariance is considered a design flaw.");
        }

        // The safe alternative: use IReadOnlyList<T> which is genuinely covariant
        // and prevents write access through the interface entirely.
        System.Collections.Generic.IReadOnlyList<Animal> safeView = dogArray;
        Console.WriteLine($"\nSafe covariant view via IReadOnlyList: {safeView[0]}");
        // safeView[0] = new Cat("Mittens"); // CS0200 — property or indexer is read-only
    }
}
▶ Output
Delegate covariance produced: Dog(Scout)
[LOG] Dog(Ranger) (Dog)

Reading through widened array reference (safe):
Dog(Fido)
Dog(Spot)

Attempting unsafe array write...
Runtime caught it: ArrayTypeMismatchException
This is why array covariance is considered a design flaw.

Safe covariant view via IReadOnlyList: Dog(Fido)
⚠️
Production Gotcha — Array Covariance in Hot Paths:Every element write through a covariant array reference triggers a CLR covariant store check — a type identity comparison that happens at runtime regardless of whether a bad type is actually assigned. In a tight loop processing millions of items, this overhead adds up. Benchmark it; switch to Span or typed arrays when it matters. And never pass arrays as covariant object[] in new APIs — use IReadOnlyList instead.
AspectCovariance (out)Contravariance (in)
Keywordoutin
Assignment directionDerived → Base (e.g. IProducer → IProducer)Base → Derived (e.g. IProcessor → IProcessor)
T allowed in return types?Yes — only in output positionsNo — CS1961 compile error
T allowed in method parameters?No — CS1961 compile errorYes — only in input positions
Real-world roleProducers, factories, read-only sequencesConsumers, comparers, handlers, validators
BCL examplesIEnumerable, IReadOnlyList, FuncIComparer, Action, IEqualityComparer
Works on classes?No — only interfaces and delegatesNo — only interfaces and delegates
Array support?Yes (unsound, runtime cost — avoid)No — arrays are only covariant
Compile-time safe?Yes, fully verified by compilerYes, fully verified by compiler
Mutability requirementType must be read-only with respect to TType must be write-only with respect to T

🎯 Key Takeaways

  • Variance is a compile-time-verified contract about data flow direction — 'out' means T only exits the type (producers), 'in' means T only enters the type (consumers). The compiler rejects violations at CS1961.
  • Covariance flows WITH inheritance (IEnumerable → IEnumerable); contravariance flows AGAINST it (IComparer → IComparer). Remembering which goes which way is the hardest part — anchor it to 'producer widens output, consumer widens input'.
  • Array covariance (string[] → object[]) is a legacy design flaw: writes through the widened reference cause ArrayTypeMismatchException at runtime, and the CLR performs a covariant store check on every array write regardless. Prefer IReadOnlyList for safe, zero-overhead covariant collection views.
  • Variance only applies to interfaces and delegates in C# — never to classes or structs. If you need variance, define an interface with 'in' or 'out' on the type parameter; the implementing class stays invariant.

⚠ Common Mistakes to Avoid

  • Mistake 1: Assuming List is assignable to List — The compiler gives CS0029 ('cannot implicitly convert type') and beginners add a cast which then fails at runtime. Fix: use IEnumerable or IReadOnlyList when you only need read access — both are covariant and the assignment is legal and safe without any cast.
  • Mistake 2: Trying to declare variance on a class instead of an interface — Writing 'public class Repository' compiles with CS1960 ('Invalid variance modifier'). Classes are always invariant because they can have mutable fields that make variance unsound. Fix: extract an interface (e.g. IRepository) and apply the variance keyword there; keep the concrete class invariant.
  • Mistake 3: Putting an 'out' type parameter in a method parameter position — e.g. 'void Add(T item)' inside an interface declared as 'IProducer' gives CS1961 ('Invalid variance: The type parameter T must be contravariantly valid'). This is the compiler protecting you — if T flows in, covariance is unsound. Fix: either remove the method that accepts T, make the method use a different non-variant type, or reconsider whether the interface should be covariant at all.

Interview Questions on This Topic

  • QCan you explain the difference between covariance and contravariance in C# generics, and give a concrete example of each from the BCL?
  • QWhy is List invariant while IEnumerable is covariant, even though both work with sequences of T? What would go wrong if List were covariant?
  • QArray covariance has been in C# since version 1.0. What's the problem with it, what runtime exception can it cause, and what's the modern type-safe alternative?

Frequently Asked Questions

What is the difference between covariance and contravariance in C#?

Covariance (out keyword) allows a generic type to be used with a more derived type than originally specified — e.g. IEnumerable can be assigned to IEnumerable. Contravariance (in keyword) allows a generic type to be used with a more general type — e.g. IComparer can be assigned to IComparer. Both are only valid on interface and delegate type parameters, and both are fully verified at compile time.

Why can't I assign List to List in C#?

List is invariant because it supports both reading and writing. If assignment were allowed, you could add a Cat to what the runtime knows is a List, corrupting the list with no compile-time warning. Use IEnumerable or IReadOnlyList when you only need read access — these interfaces are covariant (out T) and the assignment to their Animal counterparts is legal and safe.

Does variance work with classes in C#, or only interfaces?

Variance only works on generic interface and delegate type parameters. You cannot apply the 'in' or 'out' modifiers to a class or struct — the compiler will give CS1960. The reason is that classes can hold mutable state, which makes variance unsound. To get variance, extract an interface from your class, apply the keyword there, and leave the class itself invariant.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousIDisposable and using StatementNext →Middleware Pipeline in .NET
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged