Covariance and Contravariance in C# Explained — With Real-World Examples and Gotchas
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
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
Variance solves this by being surgical. Instead of making List
This is the key insight most articles skip: variance isn't magic permissiveness. It's a compile-time-verified contract about data flow direction.
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}"); } } }
Dog(Rex) — runtime type: Dog
Dog(Buddy) — runtime type: Dog
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
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
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 } }
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]
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
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
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 } }
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]
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
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
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
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 } }
[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)
| Aspect | Covariance (out) | Contravariance (in) |
|---|---|---|
| Keyword | out | in |
| Assignment direction | Derived → Base (e.g. IProducer | Base → Derived (e.g. IProcessor |
| T allowed in return types? | Yes — only in output positions | No — CS1961 compile error |
| T allowed in method parameters? | No — CS1961 compile error | Yes — only in input positions |
| Real-world role | Producers, factories, read-only sequences | Consumers, comparers, handlers, validators |
| BCL examples | IEnumerable | IComparer |
| Works on classes? | No — only interfaces and delegates | No — only interfaces and delegates |
| Array support? | Yes (unsound, runtime cost — avoid) | No — arrays are only covariant |
| Compile-time safe? | Yes, fully verified by compiler | Yes, fully verified by compiler |
| Mutability requirement | Type must be read-only with respect to T | Type 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
Why can't I assign List to List in C#?
List
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.
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.