📝 Lesson 1.1: Generic Variance and Advanced Constraints
Why can you assign an IEnumerable⟨Dog⟩ to an IEnumerable⟨Animal⟩, but not a List⟨Dog⟩ to a List⟨Animal⟩? The answer is variance — one of the subtlest, most powerful parts of C#'s type system.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain covariance, contravariance, and invariance
- Use the
outandinkeywords on generic type parameters - Recognize variance in the .NET interfaces you use daily
- Declare variance on your own generic interfaces and delegates
- Apply advanced generic constraints beyond the basics
Estimated Time: 75 minutes
Project: Design variance-aware interfaces and constrained generic methods.
In This Lesson
The Variance Puzzle
Given a class hierarchy where Dog derives from Animal (Intermediate/OOP), it feels natural that "a sequence of dogs is a sequence of animals." And indeed:
IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs; // ✅ allowed — IEnumerable is covariant
But this closely-related assignment is a compile error:
List<Dog> dogList = new List<Dog>();
List<Animal> animalList = dogList; // ❌ error — List is invariant
📖 Definition
Variance describes how the subtyping of generic type arguments relates to the subtyping of the generic type itself — whether Wrapper<Dog> is compatible with Wrapper<Animal>.
There are three cases, and the difference comes down to what the type does with T — produce it, consume it, or both:
| Kind | Keyword | Relationship | Role of T |
|---|---|---|---|
| Covariance | out | preserves direction (Dog → Animal) | output only (producer) |
| Contravariance | in | reverses direction (Animal → Dog) | input only (consumer) |
| Invariance | (none) | no compatibility | both input and output |
Covariance (out)
Covariance lets a generic type flow in the same direction as its type argument: because Dog is an Animal, an IEnumerable<Dog> is usable as an IEnumerable<Animal>. It's marked with out on the type parameter.
Look at the actual .NET declaration:
public interface IEnumerable<out T> : IEnumerable // note: out T
{
IEnumerator<T> GetEnumerator();
}
The out is a promise: "T only ever comes out of this interface (as return values), never goes in (as parameters)." That's what makes it safe — every Dog that comes out is a valid Animal, so treating the sequence as IEnumerable<Animal> can never break.
(out T = producer)"] B -->|"usable as"| C["IEnumerable of Animal"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
✅ Covariant types you already use
IEnumerable<out T>, IReadOnlyList<out T>, IReadOnlyCollection<out T>, and Func<out TResult> (its result is covariant). Notice the pattern: read-only / producing types are covariant. A Func<Dog> can be used where a Func<Animal> is expected, because whatever it returns is an Animal.
Contravariance (in)
Contravariance is the mirror image: the generic type flows in the opposite direction of its type argument. It applies to types that only consume T as input, and is marked with in.
Consider IComparer<in T>, which compares two T values (input only):
public interface IComparer<in T> // note: in T
{
int Compare(T x, T y);
}
Here's the mind-bending but logical part: a comparer that can compare any Animal can certainly compare two Dogs. So an IComparer<Animal> is usable as an IComparer<Dog> — the reverse of covariance:
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // ✅ contravariance (in)
(in T = consumer)"] B -->|"usable as"| C["IComparer of Dog"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
✅ Contravariant types you already use
IComparer<in T>, IEqualityComparer<in T>, and Action<in T> (its parameter is contravariant). An Action<Animal> can be used as an Action<Dog> — if it can act on any Animal, it can act on a Dog.
💡 Remember it as PECS-in-C#: out = comes out = producer = covariant. in = goes in = consumer = contravariant. The keyword literally tells you the direction T travels.
Invariance and Safety
A type that both accepts and returns T can't be safely varied in either direction — it's invariant. List<T> is the classic example: you can add a T (input) and read a T (output).
Here's why allowing List<Dog> → List<Animal> would be dangerous:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs; // IF this were allowed...
animals.Add(new Cat()); // ...you could add a Cat to a list of Dogs! 💥
That would let a Cat sneak into a List<Dog>, breaking type safety. To prevent exactly this, List<T> is invariant — the compiler forbids the assignment in the first place.
💡 The rule the compiler enforces
A type parameter can be covariant (out) only if it's never used as an input, and contravariant (in) only if it's never used as an output. If it's used as both, it must stay invariant. This is why variance is only allowed on interfaces and delegates — never on classes like List<T>, which typically do both.
Declaring Your Own Variance
You can add variance to your own generic interfaces and delegates when it makes sense. Suppose a read-only producer interface:
// Covariant: T only appears as a return value
public interface IProducer<out T>
{
T Produce();
}
public class DogProducer : IProducer<Dog>
{
public Dog Produce() => new Dog();
}
// Because of 'out', this assignment now works:
IProducer<Animal> animalProducer = new DogProducer();
Animal a = animalProducer.Produce(); // ✅
And a consumer interface, which can be contravariant:
// Contravariant: T only appears as a parameter
public interface IConsumer<in T>
{
void Consume(T item);
}
IConsumer<Animal> animalConsumer = new AnimalConsumer();
IConsumer<Dog> dogConsumer = animalConsumer; // ✅
⚠️ The compiler holds you to the promise
If you mark a parameter out T and then try to use T as a method input (or in T as a return type), you'll get a compile error. The variance annotation is a contract the compiler verifies — you can't accidentally break the safety it provides.
Advanced Constraints
You met basic constraints (where T : IComparable<T>, : class, : new()) in Intermediate. Here are the more advanced options and combinations:
| Constraint | Requires T to be… |
|---|---|
where T : notnull | a non-nullable type (value or reference) |
where T : struct | a non-nullable value type |
where T : class | a reference type |
where T : unmanaged | an unmanaged value type (no references inside) — useful for interop/perf |
where T : Enum | an enum type |
where T : BaseClass, IInterface, new() | satisfy several constraints at once |
Constraints combine and stack. Order matters: a base class comes first, then interfaces, then new() last:
public T CreateAndTag<T>(string tag)
where T : Entity, ITaggable, new()
{
T item = new T(); // allowed by new()
item.Tag = tag; // allowed by ITaggable
return item; // T is an Entity, guaranteed
}
Multiple type parameters can each have their own constraints:
public TResult Map<TSource, TResult>(TSource source, Func<TSource, TResult> f)
where TSource : notnull
where TResult : class
{
return f(source);
}
💡 Constraints unlock capabilities
Each constraint tells the compiler what T can do, so you can call those members. where T : struct lets you use T? as Nullable<T>; new() lets you write new T(); a base-class constraint lets you access that base's members. Without the constraint, the compiler assumes nothing and forbids the operation.
Exercise & Quiz
🏋️ Exercise: Variance-Aware Repository
Objective: Design covariant and contravariant interfaces and see the assignments they enable.
Instructions:
- Create a new project called
Variance. Define a baseclass Animalandclass Dog : Animal. - Define
interface IReadRepository<out T>withT GetFirst();(covariant — output only). - Define
interface IWriter<in T>withvoid Write(T item);(contravariant — input only). - Implement
DogRepository : IReadRepository<Dog>andAnimalWriter : IWriter<Animal>. - Show that
IReadRepository<Animal> r = new DogRepository();andIWriter<Dog> w = new AnimalWriter();both compile, and explain why in a comment.
Starter Code:
class Animal { public string Name { get; set; } = "?"; }
class Dog : Animal { }
interface IReadRepository<out T>
{
T GetFirst();
}
interface IWriter<in T>
{
void Write(T item);
}
// TODO: implement DogRepository and AnimalWriter, then do the variant assignments
💡 Hint
Covariance (out) lets a producer of a more derived type stand in for a producer of a base type (Dog repo → Animal repo). Contravariance (in) lets a consumer of a base type stand in for a consumer of a derived type (Animal writer → Dog writer).
✅ Solution
class Animal { public string Name { get; set; } = "?"; }
class Dog : Animal { }
interface IReadRepository<out T>
{
T GetFirst();
}
interface IWriter<in T>
{
void Write(T item);
}
class DogRepository : IReadRepository<Dog>
{
public Dog GetFirst() => new Dog { Name = "Rex" };
}
class AnimalWriter : IWriter<Animal>
{
public void Write(Animal item) => Console.WriteLine($"Writing {item.Name}");
}
// Covariance: a Dog-producer IS an Animal-producer (every Dog it returns is an Animal)
IReadRepository<Animal> repo = new DogRepository();
Animal a = repo.GetFirst();
Console.WriteLine(a.Name); // Rex
// Contravariance: an Animal-consumer IS a Dog-consumer (it can handle any Dog)
IWriter<Dog> writer = new AnimalWriter();
writer.Write(new Dog { Name = "Buddy" }); // Writing Buddy
🎯 Quick Quiz
Question 1: What does the out keyword on a generic type parameter enable?
Question 2: Why is List<T> invariant?
Question 3: Which constraint requires T to be a non-nullable value type?
Summary
🎉 Key Takeaways
- Variance governs whether
Wrapper<Derived>is compatible withWrapper<Base>. - Covariance (
out):Tis output-only; direction is preserved (IEnumerable<out T>,Func<out TResult>). - Contravariance (
in):Tis input-only; direction is reversed (IComparer<in T>,Action<in T>). - Invariance (default):
Tis used both ways (List<T>) — variance would break safety. Variance applies to interfaces and delegates only. - Advanced constraints —
notnull,unmanaged,Enum, and stacked base/interface/new()— unlock capabilities onT.
📚 Additional Resources
- Covariance and contravariance — Microsoft Docs
- Constraints on type parameters
- Variance in generic interfaces
🚀 What's Next?
You've deepened your command of the type system. Next, we make APIs a joy to use. In Lesson 1.2: Extension Methods and Fluent APIs, you'll add methods to existing types and design chainable, readable interfaces.
🎉 Variance decoded!
You now understand one of C#'s most subtle features. Next: designing elegant APIs.