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.
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.
A generic interface's type parameter can be declared with a variance annotation:
out T (covariant) — the type parameter may only appear in output positions (return types). In exchange, IInterface<Derived> is treated as assignable to IInterface<Base>, preserving the direction of the inheritance relationship.in T (contravariant) — the type parameter may only appear in input positions (method parameters). In exchange, IInterface<Base> is treated as assignable to IInterface<Derived>, reversing the direction of the inheritance relationship.SomeGeneric<Derived> and SomeGeneric<Base> are entirely unrelated types, regardless of the relationship between Derived and Base. Almost every generic class you've used so far — List<T>, Dictionary<TKey,TValue> — is invariant.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.
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 thereBecause 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.
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.
out TDog is an AnimalIEnumerable<Dog> is an IEnumerable<Animal>
in TDog is an AnimalIComparer<Animal> is an IComparer<Dog>
List<T>) allow neither direction — they both accept and hand out T.
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
}
IEnumerable<T>, which you met in the previous module, is covariant — T only ever flows out, through GetEnumerator() and, transitively, Current.out T to be legal, every member of the interface must use T only in output positions — return types, or out parameters. Not one single input use is allowed anywhere in the interface.in T to be legal, every member must use T only in input positions — ordinary parameters. Not one single output use anywhere.out onto any interface's type parameter and hope for the best — if any member would violate the direction, the interface itself fails to compile. This guarantee is what makes the resulting assignability rule provably safe, not just "usually fine."out T or in T, the compiler automatically allows the corresponding widened or narrowed assignment everywhere that interface is used — you don't do anything further to "activate" it.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 DogsCode → Meaning → Result:
IEnumerable<Dog> widens to IEnumerable<Animal> because the interface only ever hands T out — the inheritance direction is preserved.List<Dog> itself still cannot widen to List<Animal> — List<T> is a class, and classes can't declare variance at all, plus it both reads and writes T.IComparer<Animal> narrows to IComparer<Dog> because the interface only ever accepts T in — an Animal-comparer is perfectly capable of comparing two Dogs, since every Dog genuinely is an Animal.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 neededNeither 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.
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.
List<T> is a concrete class with real storage — an internal array holding actual T values. Its Add method genuinely needs to accept a T as input, and its indexer genuinely needs to hand one back out, so it structurally can never satisfy the "only in" or "only out" requirement variance demands. Interfaces and delegates, by contrast, only describe a contract — no storage of their own — which is what makes it possible for the compiler to check every member's direction cleanly.IEnumerable<out T>, IEnumerator<out T>, and IReadOnlyCollection<out T> are all covariant — notice they're all read-only shaped, with no way to add items. IComparer<in T>, IEqualityComparer<in T>, and Action<in T> are all contravariant — they only ever consume T, never hand it back.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.
out here is not the same out as an out parameterYou'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.
Covariant moves in the same direction as the inheritance relationship (Dog → Animal, so IEnumerable<Dog> → IEnumerable<Animal>). Contravariant moves in the opposite direction (IComparer<Animal> → IComparer<Dog>, reversed from Dog → Animal). If you remember "out flows out and forward, in flows backward," the rest follows.
Wrong — doesn't compile, because List<T> is invariant:
List<Animal> animals = new List<Dog>(); // compile errorCorrect — use a covariant interface reference instead, if you only need to read:
IEnumerable<Animal> animals = new List<Dog>(); // compilesout 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.
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.
IEnumerable<T> rather than a concrete List<T>, so callers with more specific types can pass them in directly.List<T> itself, are correctly invariant, and forcing variance where it doesn't structurally fit won't compile anyway.IEnumerable<T>, IComparer<T>, and similar built-in interfaces is the goal.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.
out T) = only hands T out → same direction as inheritance → IEnumerable<Dog> is an IEnumerable<Animal>in T) = only takes T in → reversed direction → IComparer<Animal> is an IComparer<Dog>List<Dog> is never a List<Animal>List<T> never can.List<Dog> is never a List<Animal> — generic classes are invariant, because allowing that assignment would let you insert the wrong type through the wider reference and corrupt the original list.out T) is safe on interfaces that only ever hand T out, like IEnumerable<T> — IEnumerable<Dog> is genuinely an IEnumerable<Animal>.in T) is safe on interfaces that only ever accept T in, like IComparer<T> — IComparer<Animal> works perfectly as an IComparer<Dog>.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?
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>?
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?
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?
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.