← Open in the full interactive course (progress tracking, search & more)

A List<Dog> is not a List<Animal> — but an IEnumerable<Dog> is an IEnumerable<Animal>. The difference is one keyword: out.

You know that a Dog is an Animal — that's ordinary inheritance, and it means a Dog reference can always go wherever an Animal reference is expected. So it feels completely natural to expect this to compile:

List<Dog> dogs = new List<Dog>(); List<Animal> animals = dogs; // compile error — does not compile!

And yet, this one compiles just fine:

List<Dog> dogs = new List<Dog>(); IEnumerable<Animal> animals = dogs; // compiles!

Same dogs variable, same underlying list, same "a Dog is an Animal" relationship — one assignment is rejected, the other is accepted. That's not an inconsistency in C#; it's a deliberate, carefully-designed rule called variance, and once you see why the second one is actually safe while the first genuinely isn't, the whole thing clicks into place.

In this lesson, you'll understand exactly why List<Dog> can't be a List<Animal>, how out and in let a generic interface's type parameter flex safely along an inheritance hierarchy, and how to recognize covariance and contravariance in the interfaces you already use every day. This is a solid first look — the deeper compiler internals of variance are covered later, in the Advanced module.

What Is It?

The Simple Explanation

Variance is the rule that decides whether SomeGeneric<Dog> can be used wherever SomeGeneric<Animal> is expected (or the other way around), given that Dog is an Animal. For most generic types, the answer is simply "no" — List<Dog> and List<Animal> are treated as two completely unrelated types, despite Dog and Animal being related. But for certain generic interfaces, C# lets you opt in to a controlled, safe form of flexibility: covariance (marked with out) lets a more derived type parameter substitute for a less derived one, and contravariance (marked with in) lets it work the other way around.

The Technical Definition

A generic interface's type parameter can be declared with a variance annotation:

Variance annotations can only be declared on generic interfaces and delegates — never on classes or structs. That restriction isn't arbitrary; the "Under the Hood" section later in this lesson explains exactly why.

Why Does It Exist?

The Problem

Consider why List<Dog> dogs; List<Animal> animals = dogs; genuinely cannot be allowed to compile — not because C# is being unnecessarily strict, but because it would let you break type safety at runtime. If it were allowed:

List<Dog> dogs = new List<Dog>(); List<Animal> animals = dogs; // if this were legal... animals.Add(new Cat()); // ...this would compile — Cat is an Animal! Dog firstDog = dogs[0]; // InvalidCastException — there's a Cat in there

Because animals and dogs would be the exact same list object, adding a Cat through the animals reference would silently corrupt the dogs list with something that isn't a Dog at all. The compiler has no way to catch this at compile time once it's allowed the assignment — so it refuses the assignment itself, up front, rather than let a runtime type error sneak in later. This is exactly why List<T> is invariant.

But now consider a read-only view — something that can only ever hand you items, never accept new ones:

IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() }; IEnumerable<Animal> animals = dogs; // this is genuinely safe foreach (Animal a in animals) { /* every item really is an Animal (a Dog, specifically) */ }

There's no Add method on IEnumerable<T> to exploit — you can only read items out, and every Dog you read out genuinely is an Animal. Nothing unsafe can happen here.

The Solution

Variance annotations let the compiler tell these two situations apart, purely from the interface's shape. If a type parameter is only ever handed out (as a return value) and never accepted in (as a parameter), the compiler can prove that widening from Derived to Base is safe, and marks it out T — this is covariance. If a type parameter is only ever accepted in and never handed back out, narrowing the other direction is provably safe too, marked in T — this is contravariance. List<T> can't be marked either way, because it both accepts items (Add) and hands them out (the indexer, enumeration) — mixing both directions is exactly what made the unsafe scenario above possible.

Big Picture

WHICH DIRECTION DOES THE ARROW POINT?
Covariant — out T
Dog is an Animal
↓ same direction ↓
IEnumerable<Dog> is an IEnumerable<Animal>
"Only hands T out" — safe to widen
Contravariant — in T
Dog is an Animal
↓ reversed ↓
IComparer<Animal> is an IComparer<Dog>
"Only takes T in" — safe to narrow the other way
Invariant generics (like List<T>) allow neither direction — they both accept and hand out T.

How It Works

RECOGNIZING VARIANCE, STEP BY STEP
1. LOOK AT THE INTERFACE DECLARATION
public interface IEnumerable<out T>   // covariant — note the "out"
{
    IEnumerator<T> GetEnumerator();     // T only appears as a return type
}

public interface IComparer<in T>      // contravariant — note the "in"
{
    int Compare(T x, T y);              // T only appears as parameters
}
2. CHECK WHICH DIRECTION T FLOWS IN EVERY MEMBER
3. THE COMPILER ENFORCES THIS AT THE INTERFACE DECLARATION ITSELF
4. THE ASSIGNABILITY RULE FOLLOWS AUTOMATICALLY

Simple Example

public class Animal { public string Name { get; init; } = ""; } public class Dog : Animal { } public class Cat : Animal { } // ─── Covariance: out T ─── List<Dog> dogList = [new Dog { Name = "Rex" }, new Dog { Name = "Fido" }]; IEnumerable<Dog> dogsEnumerable = dogList; IEnumerable<Animal> animalsEnumerable = dogsEnumerable; // covariance — widening is safe foreach (Animal a in animalsEnumerable) Console.WriteLine(a.Name); // Rex, Fido — reading only, nothing can go wrong // List<Dog> itself is still invariant — this still doesn't compile: // List<Animal> notAllowed = dogList; // compile error // ─── Contravariance: in T ─── public class AnimalNameComparer : IComparer<Animal> { public int Compare(Animal? x, Animal? y) => string.Compare(x?.Name, y?.Name, StringComparison.Ordinal); } IComparer<Animal> animalComparer = new AnimalNameComparer(); IComparer<Dog> dogComparer = animalComparer; // contravariance — narrowing is safe dogList.Sort(dogComparer); // an Animal-comparer works perfectly well for comparing Dogs

Code → Meaning → Result:

Real-World Example

A Repository<T>-style method that returns products, and a generic comparer used to sort a product catalog, are realistic places variance shows up without you necessarily noticing.

public class Product { public string Name { get; init; } = ""; public decimal Price { get; init; } } public class DiscountedProduct : Product { public decimal DiscountPercent { get; init; } } public class ProductRepository { private readonly List<DiscountedProduct> _discounted = []; public void Add(DiscountedProduct product) => _discounted.Add(product); // Returns IEnumerable<DiscountedProduct>, but callers only asking for // IEnumerable<Product> can accept it directly, thanks to covariance: public IEnumerable<DiscountedProduct> GetAll() => _discounted; } void PrintCatalog(IEnumerable<Product> products) // accepts ANY covariant-compatible source { foreach (Product p in products) Console.WriteLine($"{p.Name}: {p.Price:C}"); } var repo = new ProductRepository(); repo.Add(new DiscountedProduct { Name = "Headphones", Price = 59.99m, DiscountPercent = 10 }); PrintCatalog(repo.GetAll()); // IEnumerable<DiscountedProduct> flows into IEnumerable<Product> // A general-purpose price comparer for ANY Product works for the more specific // DiscountedProduct too, thanks to contravariance: public class PriceComparer : IComparer<Product> { public int Compare(Product? x, Product? y) => (x?.Price ?? 0).CompareTo(y?.Price ?? 0); } List<DiscountedProduct> discounted = [.. repo.GetAll()]; discounted.Sort(new PriceComparer()); // IComparer<Product> accepted where IComparer<DiscountedProduct> is needed

Neither of these would compile without variance — GetAll() deliberately returns the more specific IEnumerable<DiscountedProduct> (good design: be specific about what you return), yet callers who only care about the general Product shape can consume it directly. And a single, general-purpose comparer can be reused for any more specific product type, without writing a new comparer for every subclass.

Analogy

A Vending Machine vs a Mailbox

Think of a covariant IEnumerable<Dog> as a vending machine that only ever dispenses items — you can't put anything into it, only take things out. If a vending machine dispenses specifically dog treats, you can safely treat it as "a machine that dispenses animal treats" — everything that comes out really is an animal treat, just a more specific kind. Nothing bad happens by looking at it more generally.

Now think of a contravariant IComparer<Animal> as a mailbox that only ever accepts mail — you can't take anything out, only put things in. A mailbox built to accept any piece of "animal mail" can obviously also accept the more specific "dog mail" — accepting the general case means you can already handle every specific case. That's why the relationship reverses: a handler for the broader category works for the narrower one, not the other way around.

A regular List<T>, by contrast, is like a two-way delivery slot — you can both drop things in and take things out. Mixing those two directions is exactly what makes it unsafe to treat as anything other than exactly the type it was declared with.

Under the Hood

A FIRST LOOK — THE FULL COMPILER MECHANICS COME LATER
1. WHY CLASSES CAN'T DECLARE VARIANCE, ONLY INTERFACES AND DELEGATES
2. THIS IS A COMPILE-TIME TYPE CHECK, NOT A RUNTIME CONVERSION
3. FAMILIAR INTERFACES YOU ALREADY USE ARE VARIANT
4. WHAT'S DELIBERATELY OUT OF SCOPE FOR THIS LESSON

Common Confusion

1. "If Dog is an Animal, shouldn't List<Dog> always be a List<Animal>?" — no, and now you know why

This is the single most common assumption learners bring to variance, and it's wrong specifically because List<T> both reads and writes T. The "Why Does It Exist?" section above walks through exactly the bug that assumption would introduce — an Animal-typed reference letting you smuggle a Cat into a list of Dogs.

2. out here is not the same out as an out parameter

You've seen out before as a parameter modifier (void TryParse(string s, out int result)). This is a different, unrelated use of the same keyword — on a generic type parameter, out means "covariant," describing how the type parameter is allowed to be used across the whole interface, not a single parameter's passing convention.

3. Which direction is "co" and which is "contra"? A quick anchor

Covariant moves in the same direction as the inheritance relationship (DogAnimal, so IEnumerable<Dog>IEnumerable<Animal>). Contravariant moves in the opposite direction (IComparer<Animal>IComparer<Dog>, reversed from DogAnimal). If you remember "out flows out and forward, in flows backward," the rest follows.

Common Mistakes

Mistake 1 — Expecting a concrete generic class to be variant

Wrong — doesn't compile, because List<T> is invariant:

List<Animal> animals = new List<Dog>(); // compile error

Correct — use a covariant interface reference instead, if you only need to read:

IEnumerable<Animal> animals = new List<Dog>(); // compiles

Mistake 2 — Trying to add an out T type parameter as a method parameter

This won't compile — it violates the "only in output positions" rule that makes out T legal in the first place:

public interface IProducer<out T> { T Produce(); void Consume(T item); // compile error — T used as input on a covariant parameter }

Split the responsibilities, or drop the variance annotation if the interface genuinely needs to do both — this is exactly the tension that keeps List<T> invariant.

Mistake 3 — Assuming variance is a runtime conversion that copies data

Thinking IEnumerable<Animal> animals = dogs; somehow creates a new collection or copies elements into an "animal-shaped" container. It's the same object the whole time — variance is purely a compile-time typing rule about which reference types are considered compatible, not a data transformation.

When Should I Use It?

Recognize and rely on variance when

Don't reach for it when

Rule of thumb: If a generic interface only ever returns T, expect it to be covariant (out T). If it only ever accepts T as a parameter, expect it to be contravariant (in T). If it does both, expect it to be invariant — and that's fine; invariance is the safe, correct default for anything that both reads and writes.

Mental Model

Covariant (out T) = only hands T out → same direction as inheritance → IEnumerable<Dog> is an IEnumerable<Animal>
Contravariant (in T) = only takes T in → reversed direction → IComparer<Animal> is an IComparer<Dog>
Invariant (no annotation) = does both → no relationship at all → List<Dog> is never a List<Animal>

Remember:
· Variance is a compile-time typing rule, not a runtime conversion — the object never changes.
· Only interfaces and delegates can declare variance — classes like List<T> never can.
· "Out flows out and forward, in flows backward" — a quick anchor for which direction each keyword allows.

Key Takeaway


Check Your Understanding

You've seen why List<Dog> isn't a List<Animal>, but IEnumerable<Dog> is an IEnumerable<Animal>. Let's check your understanding.

1. Why does List<Animal> animals = new List<Dog>(); fail to compile?

Show answer

Correct: B

Why B is correct: As shown in "Why Does It Exist?", if this assignment were legal, you could call animals.Add(new Cat()) and silently corrupt the underlying List<Dog> with a Cat — a runtime type violation the compiler refuses to allow.

Why A is incorrect: The example assumes Dog : Animal, which is exactly why this looks like it should work — the problem is about invariance, not the inheritance relationship itself.

Why C is incorrect: Some generic interfaces, like IEnumerable<T>, do support exactly this kind of flexibility via covariance — it's specific to List<T> being invariant, not a blanket rule.

Why D is incorrect: This describes nothing about how generics actually work — a program can have many differently-typed List<T> instances simultaneously.

Reinforcement: Invariance exists specifically to prevent inserting the wrong type through a wider reference to the same underlying collection.

2. What does the out keyword mean when applied to a generic interface's type parameter, as in IEnumerable<out T>?

Show answer

Correct: B

Why B is correct: out T is the covariance annotation — it restricts T to output positions across every member of the interface, and in exchange the compiler allows widening assignments that follow the same direction as the inheritance relationship.

Why A is incorrect: This confuses two unrelated uses of the keyword out — the parameter modifier (out int result) is different from the generic variance annotation, as covered in "Common Confusion."

Why C is incorrect: It's fully enforced at compile time — both restricting how T can be used inside the interface, and enabling the resulting assignability rule.

Why D is incorrect: Variance has nothing to do with whether implementers are classes or structs.

Reinforcement: out T on a type parameter is a distinct concept from out as a parameter modifier — same keyword, different meaning depending on where it's used.

3. Given public class Vehicle {} and public class Car : Vehicle {}, which of the following compiles, assuming IComparer<in T>'s contravariance?

Show answer

Correct: A

Why A is correct: Contravariance reverses the direction — a comparer for the more general Vehicle can stand in for a comparer of the more specific Car, because it's already capable of comparing anything that's a Vehicle, Car included. This matches the IComparer<Animal>IComparer<Dog> example from this lesson.

Why B is incorrect: This reverses the actual rule — a Car-only comparer can't safely be used to compare arbitrary Vehicles, since it may only know how to compare specifically Car details.

Why C is incorrect: IComparer<T> is explicitly declared in T in the BCL, precisely so this kind of narrowing assignment is allowed.

Why D is incorrect: Only one direction is sound for a contravariant interface — allowing both would reopen the same kind of type-safety hole invariance exists to prevent.

Reinforcement: Contravariance always flows opposite to the inheritance relationship: the more general handler substitutes for the more specific one, never the reverse.

4. Why can a class like List<T> never be declared covariant or contravariant, no matter how it's written?

Show answer

Correct: B

Why B is correct: C# restricts variance annotations to interfaces and delegates. Beyond the syntax restriction, it wouldn't help anyway — as explained in "Under the Hood," a genuinely mutable, storage-backed type like List<T> structurally needs to both accept T (Add) and hand it out (the indexer), which directly conflicts with the "only in" or "only out" rule variance requires.

Why A is incorrect: The restriction isn't an incidental IL detail — it reflects a genuine structural reason classes with real read/write storage can't safely support variance.

Why C is incorrect: This is intentional, documented language design, not a defect.

Why D is incorrect: Variance is unrelated to value type vs. reference type — it's about whether a type parameter flows only in, only out, or both.

Reinforcement: Variance is reserved for interfaces and delegates because only a pure contract (no backing storage) can cleanly guarantee a type parameter flows in just one direction.

You now understand why some generic types flex safely along inheritance hierarchies and others don't. Next: connecting back to the collections you already know — List, Dictionary, HashSet, Stack, and Queue — now explained as the generic types they truly are, plus new ones worth adding to your toolkit.


dotnetmadeeasy.com — Learn C# and .NET, the right way.